/iblai-vibe-monetization-analytics
Build custom Platform admin surfaces on top of the four monetization
analytics endpoints — revenue, all-Platform subscribers, per-item
subscribers, paywalls list — plus the CancelSubscription self-service
helper that cancels the caller's own subscription on a given
(item_type, item_id).
A true admin override (cancel-on-behalf-of-user) does not exist. The cancel endpoint is hard-locked to
request.user; there is nouser_idparameter. To force-cancel another user's subscription, an operator must do so directly in the Stripe Dashboard.
Important:
MonetizationTab(see/iblai-vibe-monetization-configure) does NOT render revenue or subscriber data. The SDK ships the data-layer hooks and one self-service component, but no out-of-the-box analytics UI — this skill is the playbook for assembling that yourself.
Common setup (brand, conventions, env files, verification): see docs/skill-setup.md.
Verify the API before you call it. Fetch the live OpenAPI schema at
{dm_url}/api/docs/schema/({dm_url}=https://api.$DOMAIN/dm,DOMAINfromiblai.env, defaultiblai.app) and confirm the URL path, method, request body, and response shape for every endpoint you reach for. See /iblai-vibe-monetization → references/schema-validation.md.
Prerequisites
- Auth must be set up first (
/iblai-vibe-auth) — reuse the sameAuthorization: Token <token>wiring the rest of the app uses. .mcp.jsonconfigured with@iblai/mcp(and skills installed vianpx skills add iblai/vibe --all).iblai.envpopulated withPLATFORM,DOMAIN,TOKEN. If missing:curl -o iblai.env https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/iblai.env- Platform admin role required. All four analytics endpoints
(paywalls, Platform-wide subscribers, per-item subscribers, revenue)
are gated by
IsPlatformAdmin. Non-admin →403; anonymous →401. See/iblai-vibe-rbac. enable_monetization === truerequired. All four analytics views also run the_enforce_can_sell_itemsmixin check (returns403when the flag is off) — this applies equally to the per-item subscribers endpoint, not just the Platform-wide ones. The flag ships in the SDK'stenantsarray. See /iblai-vibe-monetization → references/platform-flags.md.- Stripe Connect onboarded with
is_ready_for_payments === true. Without it, revenue is empty and subscribers contains only free / grandfathered rows. Wire/iblai-vibe-monetization-onboardfirst.
What you'll build
- Revenue card —
useGetRevenueQuery→sales_volume,sales_count,currency. Optionally pair withuseGetStripeConnectStatusQueryfor commission percentages. - Subscribers list (Platform-wide) — paginated table via
useListSubscribersQuerywith status +item_typefilters and server-side search. - Per-item subscribers drilldown —
useListItemSubscribersQuery, typically reached from a paywall detail page or leaderboard row. - Paywalls overview —
useListPaywallsQuerywithitem_type+is_enabledfilters; useful for leaderboards, audits, or custom surfaces beyond the bundledMonetizationTab. - Self-service
CancelSubscriptionhelper — cancels the caller's own subscription on a given(item_type, item_id). NOT an admin override; the endpoint resolves the subscription viarequest.user. To cancel another user's subscription, use the Stripe Dashboard.
Step 1: Validate the API schema
Confirm the four analytics endpoints + the cancel endpoint are present before writing any code:
DOMAIN=$(grep -m1 '^DOMAIN=' iblai.env 2>/dev/null | cut -d= -f2-)
curl -sS "https://api.${DOMAIN:-iblai.app}/dm/api/docs/schema/" -o /tmp/iblai_schema.yaml
grep -E "/api/billing/platforms/\{platform_key\}/(revenue|subscribers|paywalls)/|/api/billing/platforms/\{platform_key\}/items/\{item_type\}/\{item_id\}/(subscribers|subscription/cancel)/" /tmp/iblai_schema.yaml
You should see five paths. The full per-endpoint catalog (method, query
params, response shape, source view, permission class) lives in
references/analytics-api.md.
All analytics endpoints live under the DM base ({dm_url}, e.g.
https://api.iblai.app/dm) and require Authorization: Token <DM token>
— the DM token, not the AXD token. The platform-scoped endpoints
have no canonical alternative (they aggregate across items). Only
the item-scoped subscribers endpoint has a canonical (unique_id-keyed)
counterpart.
| Endpoint | Form | Hook |
|---|---|---|
{dm_url}/api/billing/platforms/{platform_key}/revenue/ |
(platform-scoped, no canonical) | useGetRevenueQuery |
{dm_url}/api/billing/platforms/{platform_key}/subscribers/ |
(platform-scoped, no canonical) | useListSubscribersQuery |
{dm_url}/api/billing/platforms/{platform_key}/paywalls/ |
(platform-scoped, no canonical) | useListPaywallsQuery |
{dm_url}/api/billing/items/{item_unique_id}/subscribers/ |
Canonical (recommended) | direct fetch (SDK hook is composite) |
{dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/subscribers/ |
Composite (legacy) | useListItemSubscribersQuery |
Cancel reuses the subscription cancel endpoint —
canonical {dm_url}/api/billing/items/{item_unique_id}/subscription/cancel/
or composite {dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/subscription/cancel/
— via useCancelSubscriptionMutation (which still builds the composite
URL). It is the same endpoint the user-side PurchasesTab calls and is
hard-locked to request.user — operators cannot cancel on behalf of
another user through this API. See /iblai-vibe-monetization-subscription
for the portal_url vs immediate status: "canceled" branch.
Step 2: Revenue
useGetRevenueQuery({ platform_key }) returns three fields:
interface RevenueResponse {
sales_volume: string; // DRF DecimalField serializes as string, e.g. "1499.50" — coerce with Number()
sales_count: number; // number of completed sales (e.g. 12)
currency: string; // ISO 4217 lower-cased (typically "usd")
}
sales_volumeships as a string (DRFDecimalFieldserializes as"1499.50"). Coerce withNumber(data.sales_volume)before passing toIntl.NumberFormat— formatting a string yieldsNaN.
import { useGetRevenueQuery } from '@iblai/iblai-js/data-layer';
function RevenueCard({ platformKey }: { platformKey: string }) {
const { data } = useGetRevenueQuery({ platform_key: platformKey });
if (!data) return <Skeleton className="h-32 w-full" />;
const formatted = new Intl.NumberFormat(undefined, {
style: 'currency', currency: data.currency.toUpperCase(),
}).format(Number(data.sales_volume));
return (
<Card><CardContent className="p-5">
<p className="text-xs text-gray-500">Total revenue</p>
<p className="text-2xl font-semibold">{formatted}</p>
<p className="text-xs text-gray-500 mt-1">
{data.sales_count} completed sale{data.sales_count === 1 ? '' : 's'}
</p>
</CardContent></Card>
);
}
Where the numbers come from. The backend aggregates ItemPaymentRecord
rows written by Stripe webhook handlers — NOT a live read against Stripe.
Expect a short lag between checkout and the number ticking up. Currency
is a single Platform field, not a per-item breakdown; for multi-currency,
aggregate by price.currency from the subscribers list yourself.
Step 3: Commission interpretation
The commission ibl.ai takes per item type is on the Stripe Connect status endpoint, NOT on the revenue response.
import { useGetStripeConnectStatusQuery } from '@iblai/iblai-js/data-layer';
function CommissionTable({ platformKey }: { platformKey: string }) {
const { data } = useGetStripeConnectStatusQuery({ platform_key: platformKey });
// Treat as Record<string, number> — three layers disagree on which keys exist (see note below)
const commission = (data?.commission_percent ?? {}) as Record<string, number>;
const entries = Object.entries(commission);
if (entries.length === 0) return null;
return (
<ul className="text-sm space-y-1">
{entries.map(([itemType, pct]) => (
<li key={itemType}>
{itemType === 'mentor' ? 'Agent' : itemType}: {pct}%
</li>
))}
</ul>
);
}
commission_percent has a 3-way divergence — treat it as
Record<string, number> and iterate Object.entries defensively rather
than reading hardcoded keys:
| Layer | Keys declared |
|---|---|
| Backend wire | mentor, course, program, pathway (4) |
OpenAPI schema component ItemTypeCommission |
mentor, course, program (3 — no pathway) |
| SDK TypeScript type | mentor, course (2) |
The backend reads each percentage from per-Platform Config keys
(STRIPE_ITEM_COMMISSION_PERCENT_MENTOR, _COURSE, _PROGRAM,
_PATHWAY), so at runtime you may see up to four keys; never assume
exactly four are present. Render mentor as "Agent" to stay aligned
with the SDK's displayItemType helper —
see /iblai-vibe-monetization → references/item-types.md.
Display informationally only. Commission flows back to ibl.ai
automatically via Stripe Connect destination charges; do NOT subtract
from sales_volume to compute a "net" number unless explicitly asked.
Step 4: Subscribers — Platform-wide
useListSubscribersQuery returns a paginated list of every subscription
across every item on the Platform:
interface ListSubscribersParams {
platform_key: string;
status?: 'active' | 'free' | 'grandfathered' | 'trialing'
| 'past_due' | 'canceled' | 'incomplete';
item_type?: string; // 'mentor' | 'course' | 'program' | 'pathway' | 'custom:foo'
page?: number;
page_size?: number;
}
// 12 flat fields per row — flat (NOT nested under `user`)
interface SubscriberRow {
unique_id: string; user_id: number; username: string; email: string;
item_type: string; // 'mentor' | 'course' | 'program' | 'pathway' | 'custom:foo'
item_id: string; item_name: string;
status: 'active' | 'free' | 'grandfathered' | 'trialing'
| 'past_due' | 'canceled' | 'incomplete';
price: unknown; // PaywallPrice — interval, amount, currency, is_active
created_at: string; updated_at: string; // ISO 8601
}
interface ListSubscribersResponse {
count: number;
next_page: number | null;
previous_page: number | null;
results: SubscriberRow[];
}
user_id, username, and email sit at the TOP level (NOT nested
under a user object) — admin tables render who is subscribed with no
second roundtrip per row. This is the slimmer
ItemSubscriptionListSerializer; the per-item endpoint returns the
richer ItemSubscriptionSerializer shape — see Step 5.
function SubscribersTable({ platformKey }: { platformKey: string }) {
const [status, setStatus] = useState<string | undefined>();
const [page, setPage] = useState(1);
const { data } = useListSubscribersQuery({
platform_key: platformKey, status, page, page_size: 25,
});
// render data.results; paginate via data.next_page / data.previous_page
// wrap controls in a status <Select>: active | trialing | past_due |
// canceled | grandfathered | free | incomplete (omit → all statuses)
}
Pagination uses page numbers, not cursors — increment page when
next_page is non-null. Backend honors page_size as a query param.
Step 5: Subscribers — per-item
When the admin drills into a specific paywall, render that item's
subscribers with useListItemSubscribersQuery. It accepts
(item_type, item_id) instead of bare query params:
const { data } = useListItemSubscribersQuery({
platform_key: platformKey,
item_type: itemType,
item_id: itemId,
status: 'active',
});
// data.results[i] carries username, email, price, status, current_period_end
Response envelope is identical to the Platform-wide subscribers list
(count, next_page, previous_page, results), but each row is
richer — the per-item endpoint serializes through
ItemSubscriptionSerializer (~21 flat fields) instead of the slimmer
ItemSubscriptionListSerializer (12 flat fields). Extra fields you can
read here that are NOT on the Platform-wide list:
current_period_start, current_period_end, trial_end,
cancel_at_period_end, canceled_at, is_grandfathered,
grandfathered_at, billing_portal_url, metadata. The only filter
supported on this endpoint is status; if you also need filtering by
date or username, fall back to useListSubscribersQuery and filter
client-side.
Step 6: Paywalls list
useListPaywallsQuery returns every paywall configuration on the
Platform — the same rows the bundled MonetizationTab renders, but
you can pull them into any custom surface (leaderboards, audit screens,
billing-ops dashboards):
interface ListPaywallsParams {
platform_key: string;
item_type?: string;
is_enabled?: boolean; // narrow to live paywalls
page?: number;
page_size?: number;
}
interface ListPaywallsResponse {
count: number;
next_page: number | null;
previous_page: number | null;
results: PaywallConfigResponse[];
}
Common patterns:
- Active-paywalls leaderboard. Fetch with
is_enabled: true, sort byprices[0].amountor by a count from a follow-upuseListItemSubscribersQueryper row. - Paywall health audit. Fetch all paywalls and flag rows where
prices.length === 0or every price hasis_active === false— these are configured but un-sellable. See/iblai-vibe-monetization-configure. - Item-type breakdown. Group
resultsbyitem_typeto get a "paywalls per category" widget. UsedisplayItemTypesomentorrenders as "Agent".
Each PaywallConfigResponse carries prices: PaywallPrice[] inline —
no second roundtrip per row to draw the entry-level price.
Step 7: Self-service CancelSubscription helper
The SDK ships a stand-alone CancelSubscription component, but it is
not currently re-exported from @iblai/iblai-js/web-containers (the
public exports map only declares ., ./next, ./sso, ./styles).
To use it standalone, either wait for the SDK to add an export, or copy
packages/web-containers/src/components/profile/monetization/cancel-subscription.tsx
from iblai/ibl-web-frontend into your app.
import CancelSubscription from '@/components/monetization/cancel-subscription';
<CancelSubscription platformKey={currentTenant.key} />
The component renders an inline form: pick item_type
(mentor | course | program | pathway), enter an item_id, click
Look Up, and the matching subscription card appears with a
confirm-typing gate. Under the hood it uses
useLazyGetItemSubscriptionQuery for the lookup and
useCancelSubscriptionMutation — the same mutation the user-side
PurchasesTab uses.
This cancels the CALLER's own subscription, not someone else's.
ItemSubscriptionCancelView (billing/views.py:1885) is hard-locked to
request.user; there is no user_id parameter and no admin override.
Spoofing (item_type, item_id) only changes which of the caller's own
subscriptions is resolved. To force-cancel another user's subscription,
use the Stripe Dashboard.
Branch on the response. Recurring (price.interval === 'month' | 'year')
returns {portal_url} — the caller must follow that URL to finish the
cancel in Stripe's portal. Non-recurring returns the full subscription
record with status: 'canceled' immediately. Copy this branch verbatim:
if (result.portal_url) {
// open Stripe portal — recurring path
} else if (result.status === 'canceled') {
// immediate cancel — show success
}
Verify
Run /iblai-vibe-ops-test before telling the user the work is ready:
pnpm build— must pass with zero errors.- Drive the dashboard:
pnpm dev & npx playwright screenshot http://localhost:3000/admin/monetization /tmp/dash.png - Revenue card shows
sales_count >= 1on a Platform with at least one completed paid checkout, formatted in the Platform's currency. - Subscribers list paginates: incrementing
pageupdatesnext_pageto the next index (ornullon the last page). - Status filter narrows the list — switching to
canceleddropsactiverows. - Paywalls list mirrors
MonetizationTabcontent; switchingis_enabledtofalsesurfaces any disabled paywalls. - Commission table renders one row per key returned in
commission_percent. IterateObject.entriesdefensively — do NOT assert exactly four rows. Backend returns up to four, but the OpenAPI schema declares three (nopathway) and the SDK type declares two (mentor,course). - CancelSubscription lookup on one of the caller's own
subscriptions returns the subscription card; confirm-cancel either
opens a Stripe portal (recurring) or returns the full subscription
record with
status: 'canceled'(one-time). Lookup of an item the caller does not own returns404.
Common mistakes
- Revenue dashboard before webhooks land.
useGetRevenueQueryaggregatesItemPaymentRecordrows written by webhook handlers — a just-completed checkout will not appear until thecheckout.session.completedwebhook processes. Show a "last updated" timestamp or refresh-on-click. - Forgetting to coerce
sales_volume. It ships as a string ("1499.50"); pass it throughNumber()beforeIntl.NumberFormat. - Assuming multi-currency revenue.
RevenueResponse.currencyis one string. Aggregate byprice.currencyfrom subscribers if you need a per-currency breakdown. - Subtracting commission from
sales_volume. Destination charges already route commission to ibl.ai; presentingsales_volumeminus commission as "net" double-counts. - Cancel ignoring the
portal_urlbranch. Recurring subscriptions cannot be canceled server-side — Stripe requires the portal flow. Mirror theportal_urlvsstatus === 'canceled'switch from the shipped component. - Expecting
CancelSubscriptionto be an admin-override tool. The cancel endpoint resolves the subscription viarequest.userand has nouser_idparameter; the component takes onlyplatform_key, item_type, item_id, return_url?. To force-cancel another user's subscription, an operator must use the Stripe Dashboard. - Calling these endpoints with a non-admin token. Every endpoint is
gated by
IsPlatformAdmin+_enforce_can_sell_items; non-admin →403, wrong-Platform record →404. Catch them separately. - Hardcoding
tenant_keyfromlocalStorage. Read it from the SDK auth context; thetenantsarray gets rewritten on Platform switches.
MCP tools for further detail
Query the data-layer MCP for hook param shapes, filters, and cache tags:
get_hook_info(...) for useGetRevenueQuery, useListSubscribersQuery,
useListItemSubscribersQuery, useListPaywallsQuery, and
useGetStripeConnectStatusQuery (the last one carries the
commission_percent dict and is_ready_for_payments gate).
Files in this skill's scope
Frontend (read via gh api repos/iblai/ibl-web-frontend/contents/<path>):
packages/data-layer/src/features/monetization/{custom-api-slice,types,constants}.ts— query defs, response/param types, and endpoint paths.packages/web-containers/src/components/profile/monetization/cancel-subscription.tsx— the shippedCancelSubscriptioncomponent (not currently re-exported from@iblai/iblai-js/web-containers; copy locally to use).
Backend (in ibl-dm-pro/web/ibl-dm-core-apps/ibl-dm-billing-app/billing/):
views.py:2156—PlatformRevenueView(IsPlatformAdmin).views.py:2073—PlatformSubscribersView(IsPlatformAdmin, paginated,status+item_typefilters).views.py:2030—ItemSubscribersView(IsPlatformAdmin, paginated,statusfilter).views.py:2115—PlatformPaywallsView(IsPlatformAdmin, paginated,item_type+is_enabledfilters).views.py:1885—ItemSubscriptionCancelView(IsEdxAuthenticated, hard-locked torequest.user; no admin override, nouser_idparam).dl_iblai_services_app/services/stripe/item_paywall_handlers.py—handle_item_checkout_completed, the Stripe webhook handler that writes theItemPaymentRecordrows the revenue endpoint aggregates. (views.py:2333is the post-redirectItemCheckoutCallbackView, not the webhook entrypoint; both callhandle_item_checkout_completed.)dl_iblai_services_app/models/stripe_connect.py:120—commission_percentproperty reading the fourSTRIPE_ITEM_COMMISSION_PERCENT_{MENTOR,COURSE,PROGRAM,PATHWAY}keys.
Full per-endpoint catalog (params, response shape, error modes):
references/analytics-api.md.
Related skills
/iblai-vibe-monetization— Family index, schema-validation, Platform flags./iblai-vibe-monetization-onboard— Stripe Connect;commission_percent+is_ready_for_paymentslive on Connect status./iblai-vibe-monetization-configure— Paywall create/edit; the rowsuseListPaywallsQueryreturns are the rowsMonetizationTabedits./iblai-vibe-monetization-subscription— User-side counterpart; details theportal_urlbranch ofCancelSubscription./iblai-vibe-auth— Token wiring./iblai-vibe-rbac—IsPlatformAdmingate.- Brand: BRAND.md