Mentor Analytics API
The mentor dashboard (/manage/analytics) in a single request: session counts,
delivered hours, the students behind them, the money they produced, and the two
breakdowns.
- Base URL:
/api/v1ยท Auth: Sanctum bearer token ยท Casing:snake_case - Related: earnings-api.md (the money surface this shares its definitions with) ยท booking-api.md (the sessions behind the counts) ยท analytics-api-response.md (the reply to the frontend spec, and why four fields differ from it)
GET /mentor/analytics
Own data only. The mentor profile is taken from the token, so there is no id to
tamper with. A caller without a mentor profile gets a 404.
Query parameters
| Param | Type | Required | Notes |
|---|---|---|---|
period |
string | no | Default last_30_days |
compare |
boolean | no | Default true |
period accepts either vocabulary, because this page also renders
/mentor/earnings/summary and forcing one screen to hold two spellings is how
bugs get written:
| Canonical | Short form |
|---|---|
last_7_days |
7d |
last_30_days |
30d |
last_90_days |
90d |
last_12_months |
12m |
all_time |
all |
Anything else is a 422 โ a silent fall back to the default would show a
mentor the wrong month with no indication it had happened. The response echoes
the resolved window in period.value, so a client sending short forms never has
to guess what it got.
compare=false skips measuring the previous window, halving the work for a
dashboard that polls the figures without redrawing the change indicators. Every
change_percent is then null.
Response
{
"status": "success",
"message": "Analytics retrieved successfully.",
"data": {
"period": {
"from": "2026-08-09", "to": "2026-09-08",
"value": "last_30_days", "label": "Last 30 days"
},
"bucket": "daily",
"currency": "USD",
"stats": {
"total_sessions": { "value": 42, "change_percent": 12.5 },
"completed_sessions": { "value": 31, "change_percent": -4.0 },
"total_hours": { "value": 25.5, "change_percent": 8.0 },
"total_students": { "value": 87, "change_percent": 5.0 },
"total_earnings": {
"value": { "minor": 124000, "major": "1240.00", "formatted": "USD 1,240.00" },
"change_percent": 22.0
},
"average_rating": { "value": null, "change_percent": null },
"profile_views": { "value": null, "change_percent": null }
},
"sessions_by_status": {
"requested": 4, "confirmed": 7, "paid": 3,
"completed": 31, "cancelled": 2, "refunded": 1
},
"sessions_over_time": [
{
"label": "1 Sep", "date": "2026-09-01",
"total": 3, "completed": 2, "cancelled": 1,
"earnings": 34000,
"earnings_by_currency": { "USD": 34000 }
}
],
"session_breakdown": {
"by_provider": [{ "key": "google_meet", "label": "Google Meet", "count": 18 }],
"by_session_model": [{ "key": "one_time", "label": "One Time", "count": 25 }]
},
"earnings": {
"is_multi_currency": false,
"currencies": [
{
"currency": "USD",
"total": { "minor": 124000, "major": "1240.00", "formatted": "USD 1,240.00" },
"change_percent": 22.0
}
]
}
}
}
Errors: 401 unauthenticated ยท 404 no mentor profile ยท 422 invalid
period
What "in this period" means
A booking is counted in the window its session was scheduled to happen in โ
the primary slot's starts_at โ not when it was booked and not when its status
last moved.
That is the only choice under which the numbers on the page agree with one
another. A session booked in August for September is September's work; a mentor
checking "sessions this month" against their own calendar is comparing like with
like; and a cancellation still occupies the slot it was going to occupy, which is
what makes a cancellation rate mean anything. Bucketing by status timestamps
instead would put a session's completion in a different bucket from the session,
and sessions_over_time would stop summing to total_sessions.
Money is the deliberate exception. Earnings are bucketed by when the payment was paid, because that is the definition earnings-api.md already uses, and two surfaces on one screen disagreeing about a mentor's income is worse than one surface using two clocks.
A consequence worth stating plainly: a free session counts in total_sessions
and contributes nothing to earnings. The two figures are not meant to
reconcile.
Bucketing
period |
bucket |
|---|---|
last_7_days, last_30_days |
daily |
last_90_days |
weekly |
last_12_months |
monthly |
all_time |
monthly, falling back to yearly past the monthly cap |
Buckets are zero-filled: an empty period appears with zeros rather than being absent. A chart with gaps interpolates across them and shows a mentor income in a week they had none.
label is formatted server-side โ 12 Mar daily and weekly (the week's opening
day), Mar monthly (Mar 2026 when the range crosses a year), 2026 yearly.
Every point also carries an ISO date, so you can relabel without parsing.
All-time opens at the mentor's first scheduled session rather than at the epoch, so the chart does not lead with years of empty buckets.
Money
Every figure is net of the platform fee โ amount โ platform_fee โ refunded
โ the single definition shared with /mentor/invoices and
/mentor/earnings/summary. It counts all three revenue streams: session
bookings, course enrolments and community subscriptions. They live in one
polymorphic payments table, so including them is free, and an analytics figure
that disagreed with the earnings card on the same dashboard would be
indistinguishable from a bug.
Two representations, because one cannot be both correct and simple:
stats.total_earningsis the headline figure in the mentor's own trading currency (data.currency). Simple, and what most mentors need.earnings.currenciesis the complete answer, one block per currency, never summed and never converted โ there is no FX rate anywhere in this system.earnings.is_multi_currencytells you when the headline figure is a partial view, and is the signal to render the rows instead of the card.
sessions_over_time[].earnings mirrors the same split: a bare minor-unit integer
in the primary currency (the chart only plots it, so a money triple per point
would treble the payload), plus earnings_by_currency for the full picture.
change_percent
The change against the immediately preceding window of the same length, measured by the identical code path โ two figures computed different ways produce a number that is not a comparison of anything.
It is null, never 0, whenever there is nothing to compare against:
compare=false, an all-time window (there is no window before all of history),
or a previous window of zero. That last case is an increase from nothing, which
has no finite percentage; both "0%" and "100%" would be lies, so the client hides
the indicator.
total_hours
Delivered time only, in hours to one decimal. A cancelled session consumed a slot but no hours, and counting it would inflate the one figure a mentor is most likely to check against their own records.
Completed follow-ups count, since they are real delivered sessions with their own slots. They are not counted as separate bookings โ a booking with three follow-ups is one booking โ so they only ever move this figure.
total_students
Distinct people, not bookings: a mentee who books four sessions counts once. Spans session bookings and course enrolments in the window, de-duplicated on the user, so a mentee who both books and enrols is also one student.
The two null stats
average_rating and profile_views are always null, and are present only
so the client can render the same object shape for every card.
- There is no mentor or session review anywhere in this system.
mentor_profiles.average_ratingis a column nothing writes, so it is permanently0.00; serving it would be inventing a rating. - Profile views are not tracked at all โ no table, no write path.
null is the spec's own signal to hide an indicator, so these cards stay hidden
rather than showing a mentor a fabricated zero. If either is built later it will
start returning a number in this same shape, with no other change to the
response.
Breakdowns
Both are ordered by count, biggest slice first.
by_providercomes from the meeting room minted for the slot. A booking with no room yet โ a request nobody has confirmed โ contributes tototal_sessionsbut to no provider, so this breakdown can legitimately sum to less than the total.by_session_modelcomes from the session type:regular_weekly,recurring,one_time. (The frontend spec's example showedone_on_one/1-on-1; no such model exists โ capacity is a separate field on the session type.)
A value with no matching enum case still appears, labelled with its raw key, rather than being dropped: it is real volume, and silently omitting it would make the breakdown fail to add up.