---
title: "Get Performance Metrics"
method: GET
path: "/api/analytics/performance"
tags: ["analytics"]
---

# Get Performance Metrics

`GET /api/analytics/performance`

## Request

- Base URL: `https://api.vruum.ai`
- URL: `https://api.vruum.ai/api/analytics/performance`
- Auth: `HTTPBearer` (a scheme the document does not define)

Get outreach performance metrics: time-series trends and/or conversion funnel.

Set view='timeseries' for daily counts of a single metric (contacts/people/replies/meetings).
Set view='funnel' for aggregate conversion data: contacted->replied->met funnel with rates,
reply rates by channel, and LinkedIn pipeline breakdown.
Set view='both' for everything in one call.

For T1s sent over time: view='timeseries', metric='contacts'.
For reply rate and funnel: view='funnel'.
Operators: pass for_company to get metrics for a specific client.

UNIT CONVENTIONS USED IN RESPONSES
Every count field is one of {messages, unique_people, plans, campaigns, days}; see the
Returns block for the unit attached to each field. Note that timeseries 'contacts' and
funnel.contacted are BOTH unique_people (i.e. unique people, deduped per person_id),
NOT message counts.

DATE-FILTER COLUMN PER METRIC (timeseries)
- metric='people':    filters on company_people.created_at — start_date / end_date
- metric='contacts':  filters on messages.sent_at (sequence_number=1 only) — start_date / end_date
- metric='replies':   filters on company_people.replied_at — start_date / end_date
- metric='meetings':  filters on outcomes.observed_at (meeting_booked / meeting_held
  attributed to an outreach send) — start_date / end_date
ONE SOURCE FOR MEETINGS: funnel.meetings, stage_funnel.meetings_all and the 'meetings'
timeseries read the same outcomes ledger under the same attribution rule. They differ
only in the clock: the funnel windows on the SEND (people sent in the window who met,
whenever), the timeseries on the MEETING (people whose meeting fell in the window).
A meeting with a person no outreach send reached is in neither.
reply_rates_by_channel and stage_funnel share one scope per response mode —
the stage funnel is derived from the same per-channel counts, never from a
separate query.

Returns (selected fields, with unit + scope + date-filter annotations):
    view (str):
        Echoes the requested view.

    timeseries (object, when view='timeseries' or 'both'):
        metric (str): The metric name.
        data (list of {date, count}):
            date (str): YYYY-MM-DD.
            count (int): unit = unique_people for ALL four metrics (T1-sent dedupe by
            person; company_people rows for people/replies; for meetings, each person
            once per window at their first meeting outcome in it).

    funnel (object, when view='funnel' or 'both'):
        total_people (int, unit=unique_people, scope=non-archived company_people in
            tenant, no date filter): denominator for contact_rate.
        contacted (int, unit=unique_people, scope=non-archived; date-filter:
            company_people.last_message_sent_at): distinct people with at least one
            sent message in window. NOT the total message count.
        replied (int, unit=unique_people, scope=non-archived; date-filter:
            company_people.replied_at): distinct people who replied. NOT the total
            reply-message count.
        meetings (int, unit=unique_people, scope=includes archived; date-filter:
            the send's messages.sent_at): send-cohort people with a meeting_booked /
            meeting_held outcome, whenever observed. Equal to stage_funnel.meetings_all.
            A meeting is a permanent positive outcome so it counts regardless of
            archive state.
        summary (str): human prose summary of the funnel.

    conversion_rates (object): contact_rate, reply_rate, meeting_rate, booking_rate
        — percentages computed from the funnel counts above, NOT independent measures.

    reply_rates_by_channel (list, when view='funnel' or 'both'):
        One entry per channel actually used. Per-channel entries (unit=unique_people
        for sent/replied/accepted):
        - 'linkedin_connection': sent + accepted + acceptance_rate (NOT a reply_rate;
          connection requests are top-of-funnel, replies happen on follow-up DMs).
          note: "Acceptance rate, not reply rate. Replies happen on follow-up messages."
          accepted counts DISTINCT cohort people with a connection_accepted
          OUTCOME on the cohort send's action (webhook-observed or
          reply-inferred; organic connections have no cohort send and are
          structurally excluded).
        - 'linkedin_message': sent + replied + reply_rate (follow-up DMs sent to
          connected people).
        - 'email': sent + replied + reply_rate.
        - 'linkedin_inmail': sent + replied + reply_rate.

    stage_funnel (object, when view='funnel' or 'both'):
        VRU-862 M3.4: derived with reply_rates_by_channel from ONE
        spine cohort projection (actions × outcomes), so the sections
        agree by construction. The frame is COHORT-scoped: the
        population is the outreach sends in the window (messages.sent_at;
        status sent/replied/bounced — bounced sends count as sent), and
        every later stage is a subset of that cohort via its actions'
        outcome rows, whenever observed. No rate can exceed 100%.
        Objective scoping keys on the send's own authoring objective
        (messages.objective_id — VRU-878 PR B; a legacy campaign is an
        objective now). Each rate in
        reply_rates_by_channel also ships a *_detail object carrying
        numerator, denominator, and metric_definition_version, and the
        response includes metric_definitions (the versioned contracts).
        connection_requests_sent (int, unit=unique_people): the
            linkedin_connection send cohort.
        connections_accepted (int, unit=unique_people): cohort members
            with a connection_accepted outcome (webhook-observed or
            reply-inferred; organic connections without a Vruum invite
            cannot appear — they have no cohort send).
        acceptance_rate (float, percent): connections_accepted /
            connection_requests_sent.
        followups_sent (int, unit=unique_people): the linkedin_message
            send cohort.
        followup_replies (int, unit=unique_people): cohort members with
            a STANDING reply outcome (reversals fold out; the outcome's
            observed time is the reply's own, whenever it landed).
        followup_reply_rate (float, percent): of the follow-up DMs SENT
            (NOT of accepted connections).
        meetings (int, unit=unique_people): the cohort's meeting people who
            ALSO hold a standing reply (the numerator of
            reply_to_meeting_rate). meetings_all is every cohort person with
            a meeting outcome (meeting_booked/meeting_held attributed to
            cohort actions, influence sets included) and equals
            funnel.meetings.
        reply_to_meeting_rate (float, percent): a SUBSET rate — cohort
            people with BOTH a standing reply and a meeting outcome, over
            cohort standing repliers; reply_to_meeting_rate_detail carries
            the counts + contract version. Bounded by construction
            (meetings without a reply appear in meetings_all, not here).
        (The response also carries `coverage`: cohort messages without a
        spine action can enter denominators but never numerators —
        coverage.complete=false labels a degraded read honestly.)
        summary (str): human prose summary.
            "Biggest drop:" picks the largest absolute drop among
            (connection_requests_sent - connections_accepted),
            (followups_sent - followup_replies),
            (replied - meetings). This is a raw-count heuristic; it can name
            "connection acceptance" even when the acceptance rate is healthy
            (e.g. 80%) because the absolute drop is still the largest of the three.

    connections (object, when view='connections'):
        total_requested (int, unit=plans).
        acceptance_rate (float, percent of plans).
        avg_time_to_accept_hours (float|null).
        withdrawal_rate (float, percent): plans auto-withdrawn / total requested.
        retry_success_rate (float): connected-after-retry / completed-retried plans.
        by_status (object): plan counts keyed by connection_status.

## Query parameters

- `view` string: What to return: 'timeseries' for daily counts of a single metric, 'funnel' for aggregate conversion data, 'both' for everything in one call.
- `metric` string, nullable: Metric for timeseries view. Each value documents its own scope + date-filter column (units in parentheses): 'people' (unique_people) = new company_people rows for this tenant, filtered on company_people.created_at — i.e. imports per day; 'contacts' (unique_people) = T1 messages ONLY (messages.sequence_number=1), deduped per person, filtered on messages.sent_at — follow-up touches NOT counted (to see total send activity per day, query messages table directly); 'replies' (unique_people) = distinct company_people whose replied_at falls in window, filtered on company_people.replied_at (NOT the original message's sent_at); 'meetings' (unique_people) = distinct people with a meeting_booked or meeting_held outcome observed in window, attributed to an outreach send (the funnel's ledger and attribution rule), filtered on outcomes.observed_at. Required when view includes 'timeseries' or 'both'.
- `start_date` string, nullable: Start date (YYYY-MM-DD), inclusive. Filters on the metric-specific timestamp column documented in `metric` for timeseries view; the funnel view filters the send cohort on messages.sent_at (contacted, replied and meetings are that cohort's people). Omit for last-30-days default on timeseries; omit for all-time on funnel.
- `end_date` string, nullable: End date (YYYY-MM-DD), inclusive of the whole end day. Funnel-view bounds (company_people, messages, outreach_plans) widen a bare date to end-of-day UTC; timeseries 'contacts' uses its next-day exclusive bound. Applied to reply_rates_by_channel and stage_funnel in BOTH modes (unscoped and objective-scoped).
- `objective_id` string, nullable: Filter results to objective(s) (UUID, comma-separated for several). Applies to all funnel totals, connections, and timeseries metrics; the per-channel/stage-funnel breakdown scopes to the FIRST objective when several are given. A legacy campaign is an objective now — pass its objective id.

## Response `200`

Successful Response

- unknown

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/vruum/apis/vruum-public-api.md) · [All operations](https://skmtc.dev/vruum/apis/vruum-public-api/llms.txt) · [OpenAPI document](https://skmtc.dev/vruum/apis/vruum-public-api/revisions/b8ada111c30a?raw)
