---
title: "Everything the affiliate dashboard paints, for one period (spec 3.8)."
method: GET
path: "/api/v1/affiliate/stats"
tags: ["Affiliate"]
---

# Everything the affiliate dashboard paints, for one period (spec 3.8).

`GET /api/v1/affiliate/stats`

## Request

- Base URL: `https://api.avatarfactory.io` (the document declares no server; this is the origin it was published from)
- URL: `https://api.avatarfactory.io/api/v1/affiliate/stats`
- Auth: one of:
  - API key in cookie `session`
  - HTTP bearer

<br/>Open to EVERY affiliate status: a pending applicant reads the same<br/>payload with every number at zero plus the checklist that tells them what<br/>happens next, which is what makes the dashboard available from day one.<br/>Only a user with NO affiliate row at all is refused, with 404<br/>`not_an_affiliate`, the same code `/referrals` and `/profile` answer for<br/>the same condition. (Spec 2 wrote it `not_affiliate`; one module speaking<br/>one word about one fact beats matching the prose.)<br/><br/>`range` is one of `today`, `24h`, `7d`, `30d`, `ytd`, `365d`, defaulting<br/>to `30d` when it is absent. ONE period covers the whole page, so the<br/>tiles, the chart, the funnel, the activity feed and the two card<br/>previews are all counted over it; the balances, the next payout and the<br/>checklist are all time and say so on the page. An unrecognised word is<br/>422 `invalid_range` in this module's error shape rather than a silent<br/>fall back to the default: a client asking for a window we do not have<br/>should be told, not quietly answered about a different one.<br/><br/>The response is cached for a minute, privately. The numbers move on<br/>webhook time, not on request time, so a dashboard that re-renders four<br/>cards in a second should not run these aggregates four times; `private`<br/>keeps one affiliate's figures out of any shared cache between us and them.<br/><br/>Serialized with `mode="json"`, so every date and datetime ships as ISO<br/>8601 ("2026-09-11", "2026-09-11T12:00:00Z"). Flask's default provider<br/>renders a bare `date` as an HTTP-date ("Fri, 11 Sep 2026 00:00:00 GMT"),<br/>which is unusable as the key of a chart bucket. This matches what most of<br/>the app's routes already do; the older affiliate routes do not, which is<br/>a wart to clean up separately rather than in the middle of this one.

## Query parameters

- `range` 'today' | '24h' | '7d' | '30d' | 'ytd' | '365d'

## Response `200`

OK

- AffiliateStatsResponse: Everything the dashboard paints, in one call (spec 2). Open to every status: a pending affiliate reads the same shape with every number at zero, which is what makes the day-one dashboard possible. `counting_since` is the approval moment (null until then) and is informational, not a filter: clicks are refused before approval and a referral cannot exist without one, so there is nothing earlier to exclude. All day buckets are UTC (see the note at the top of this section). PERIOD BOUND, all of them over the one selected period: `totals`, `rates`, `revenue_referred_cents`, `series`, `funnel`, `activity`, and the `referrals` and `payouts` previews with their two totals. ALL TIME, whatever the filter says: `lifetime`, `balances`, `next_payout` and `next_steps`. The captions on the page say which is which, and `period_label` is the words they use for the first group.
  - `activity` ActivityItem[]
    - `amount_cents` integer, nullable
    - `at` string, date-time, required
    - `kind` 'clicks' | 'referral_attributed' | 'first_payment' | 'commission_available' | 'payout_paid', required
    - `label` string, required
    - `masked_email` string, nullable
  - `balances` BalancesView
    - `available_cents` integer
    - `lifetime_cents` integer
    - `paid_cents` integer
    - `pending_cents` integer
  - `counting_since` string, date-time, nullable
  - `funnel` FunnelView: Clicks to signups to trials to paying, for the range. Same numbers as `totals`, carried separately so the funnel component takes one object.
    - `clicks` integer
    - `paying` integer
    - `signups` integer
    - `trials` integer
  - `lifetime` StatsLifetime: The all-time numbers, which never move when the range tab changes. `paying_now` is a snapshot, not a total: the referrals whose status right now is `paying` or `past_due`. A past-due subscriber is still a customer the affiliate earned, they are simply late.
    - `clicks` integer
    - `paying_now` integer
    - `revenue_referred_cents` integer
    - `signups` integer
  - `next_payout` NextPayoutView, required: The next manual payout run (spec 2, R11). `date` is the first day of the next month in UTC. `estimated_cents` is what would be paid if the run happened on that date: everything already available plus every pending commission that matures before it. Below `minimum_cents` the balance rolls into the following run instead.
    - `below_minimum` boolean
    - `date` string, date, required
    - `estimated_cents` integer
    - `minimum_cents` integer
  - `next_steps` NextStepItem[]
    - `detail` string, nullable
    - `disabled` boolean
    - `done` boolean
    - `href` string, nullable
    - `key` string, required
    - `label` string, required
  - `payouts` PayoutItem[]
    - `amount_cents` integer, required
    - `currency` string, required
    - `id` integer, required
    - `method` string, required
    - `note` string, nullable
    - `paid_at` string, date-time, nullable
    - `period_end` string, date, required
    - `period_start` string, date, required
    - `reference` string, nullable
    - `status` string, required
  - `payouts_total` integer
  - `period_label` string, required
  - `range` 'today' | '24h' | '7d' | '30d' | 'ytd' | '365d', required
  - `rates` StatsRates: The two conversion rates, as floats 0..1, over the RANGE's totals. Null, never zero, when the denominator is zero: "no clicks yet" and "100 clicks and no signups" are different facts and a dashboard that printed 0% for both would be lying about the first one.
    - `click_to_signup` number, nullable
    - `signup_to_paying` number, nullable
  - `referrals` ReferralItem[]
    - `first_paid_at` string, date-time, nullable
    - `gross_revenue_cents` integer, required
    - `interval` string, nullable
    - `last_paid_at` string, date-time, nullable
    - `masked_email` string, required
    - `net_commission_cents` integer, required
    - `plan_slug` string, nullable
    - `signed_up_at` string, date-time, nullable
    - `status` 'signed_up' | 'trial' | 'paying' | 'past_due' | 'churned' | 'refunded', required
  - `referrals_total` integer
  - `revenue_referred_cents` integer
  - `series` SeriesPoint[]
    - `at` string, date-time, nullable
    - `clicks` integer
    - `date` string, date, nullable
    - `earnings_cents` integer
    - `signups` integer
  - `since` string, date-time, required
  - `status` string, required
  - `totals` StatsTotals: What happened inside the range. EVERY field counts referrals ATTRIBUTED IN THE RANGE, never all of them. `signups` is how many there were, `trials` how many of those ever started a trial (the subscription carries a `trial_end` for life, so this is not "trialing right now"), `paying` how many of those have at least one paid invoice booked in the range. The last three are CURRENT status counts among the referrals attributed in the range, so a user who signed up in the range and churned yesterday is counted once, under `churned`.
    - `churned` integer
    - `clicks` integer
    - `past_due` integer
    - `paying` integer
    - `refunded` integer
    - `signups` integer
    - `trials` integer

## Other responses

- `404`: Not Found
- `422`: Unprocessable Entity

---

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