Last updated

/iblai-vibe-monetization

Mirrored from iblai/vibe · skills/billing/iblai-vibe-monetization/SKILL.md. This page is generated — edit it in the repository, not here.

Reference skill for ibl.ai's item-level monetization system — selling specific agents / courses / programs / pathways or custom items behind a Stripe Connect Express paywall. This is the family index: it covers authentication, the live OpenAPI schema, the two Platform flags, the RTK Query data layer, RBAC, and points at every workflow sub-skill. Start here for orientation, then jump to the sub-skill that matches the surface you are building.

Common setup (brand, conventions, env files, verification): see docs/skill-setup.md.

Authentication

{dm_url} and DM token. Throughout this family, {dm_url} resolves to your data-manager host — https://api.$DOMAIN/dm with DOMAIN from iblai.env (default iblai.app); in TypeScript compose it as `${apiBase}/dm`. The auth token is the DM token — not the AXD token. The two are different tokens issued by different services; using the AXD token against {dm_url} returns 401. The SDK attaches the DM token automatically via SERVICES.DM.

Every monetization endpoint lives under {dm_url}/api/billing/ or {dm_url}/api/service/platforms/{platform_key}/stripe/connect/ and requires a Platform-scoped DM token:

Authorization: Token <DM token>

This is the DRF Token scheme — NOT Bearer. The token binds to exactly one Platform at the moment of issue; the Platform is inferred from the token on every request (and also appears in the URL path for scoped endpoints). Records belonging to a different Platform return 404 Not Found, never 403 Forbidden — returning 403 would leak the existence of cross-Platform records.

Several endpoints intentionally skip the auth header — AllowAny covers public per-item pricing (by (item_type, item_id) and by paywall item_unique_id), the two /checkout-guest/ variants (guest buy by item or by price uuid), and the two checkout-callback endpoints. The full RBAC table lives in references/rbac-matrix.md.

For token wiring inside a Next.js app see /iblai-vibe-auth.

Canonical vs composite endpoints

Every item-keyed monetization endpoint now exposes two equivalent URLs:

  • Canonical (recommended) — {dm_url}/api/billing/items/{item_unique_id}/<suffix>/ (or {dm_url}/api/billing/items/prices/{price_unique_id}/<suffix>/ for price-keyed routes). The backend resolves (platform_key, item_type, item_id) from the UUID at dispatch time.
  • Composite (legacy, still supported) — {dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/<suffix>/. Use this when you already have the triple (e.g. a webhook payload, a freshly created paywall, a migration script).

Prefer canonical for new client code: URL-stable across item-type renames, no need to carry platform_key / item_type in every call. Composite stays valid forever. Full table in references/api-overview.md and the RBAC mapping in references/rbac-matrix.md — both forms share permission_classes.

Validate against the live OpenAPI schema

URL paths and response shapes in this family are documented for orientation, but the live schema mirrors what is actually deployed. Before writing any code that constructs a URL, fetch the schema:

DOMAIN=$(grep -m1 '^DOMAIN=' iblai.env 2>/dev/null | cut -d= -f2-)
DM="https://api.${DOMAIN:-iblai.app}/dm"
curl -sS -o /tmp/iblai_schema.yaml "$DM/api/docs/schema/"
grep -E "^(  )?/api/billing|^(  )?/api/service/platforms/\{platform_key\}/stripe/connect" /tmp/iblai_schema.yaml

Schema is OpenAPI 3.0.3 YAML at {dm_url}/api/docs/schema/ (browsable at {dm_url}/api/docs/; e.g. https://api.iblai.app/dm/api/docs/schema/). The info.version field exposes the deployed ibl-data-manager build — it rolls forward each release, so do not pin to a specific value. The exact fetch routine and drift-detection workflow live in references/schema-validation.md.

Monetization vs credits

These look similar but are two different products. Mixing them up is the most common mistake.

/iblai-vibe-monetization (this family)/iblai-vibe-credit
SellsA specific item — one agent / course / program / pathway / customPlatform-wide credits + plan tier
Platform flagenable_monetization (admin + user surfaces) + show_paywall (credit widget gating only)show_paywall only
Stripe accountPlatform's own Stripe (Stripe Connect Express)ibl.ai main Platform Stripe
Money flowBuyer → Platform's Stripe (commission to ibl.ai)Buyer → ibl.ai
Buyer seesPaywallModal with per-item pricing tiersCreditBalance navbar widget + Stripe Pricing Page
Admin seesMonetizationTab inside Account(none — managed by ibl.ai)
AudienceEnd users paying the Platform directlyOrg admins topping up their org's credit pool

If the goal is selling specific content, you are in the right family. If the goal is topping up an ibl.ai credit pool, switch to /iblai-vibe-credit.

Platform flags

Two Platform flags gate every monetization surface. Both are stored on the SDK's Tenant interface (packages/web-utils/src/types/index.ts) and surface via the tenants array in localStorage. They are configured by an ibl.ai operator — there is no in-app toggle.

FlagGatesWhere it's checked
enable_monetizationAdmin MonetizationTab inside Account AND user PurchasesTab inside Profileaccount.tsx, profile/index.tsx
show_paywallCreditBalance widget in navbar AND Account's Billing tabnavbar, account.tsx

Note: Profile's Purchases tab gates on enable_monetization — NOT show_paywall. Read the full gating matrix in references/platform-flags.md.

If both flags are off, none of the monetization UI renders even if the code is wired correctly. Direct app authors to their ibl.ai operator when the flags need to change.

Item-type normalization

The backend uses mentor as the canonical item_type for AI agents, but the SDK presents it as "Agent" via the displayItemType helper from paywall-utils.ts. The same helper strips the custom: prefix from custom item types. Use it whenever you render an item type so the rebrand stays consistent.

API valueUI value
mentorAgent
coursecourse
programprogram
pathwaypathway
custom:foofoo

The universal item key is (item_type, item_id, platform) — foreign-key-less by design so any sellable thing can be paywalled. Backend uniqueness: (item_type, item_id, platform) on ItemPaywallConfig; (item_type, item_id, platform, user) on ItemSubscription.

Custom items are slugged by slugify() (lowercases, strips non-word chars, collapses whitespace/dashes) and stored with the custom: prefix so the strategy router falls through to GenericCheckoutStrategy. The wizard creates them dormant (is_enabled: false, allow_free_tier: false, trial_period_days: 0, grandfathering_strategy: 'free_forever'), then promotes via the Paywall step.

buildOnSuccessfulPaymentUrl defaults — derived from authURL plus item type:

Item typeDefault on_successful_payment
mentor / agenthttps://mentorai.{ext}/platform/{platformKey}/{itemId}
coursehttps://skillsai.{ext}/courses/{itemId}
programhttps://skillsai.{ext}/programs/{itemId}
pathwayundefined — no default; admin must set manually
custom:*Operator's "Product URL" from the wizard

Full mapping + BUILT_IN_ITEM_TYPES enum + the slugify regex are in references/item-types.md.

The monetization API slice (RTK Query)

The monetization slice ships in @iblai/iblai-js/data-layer and is registered automatically by initializeDataLayer(). Reducer path: monetizationApiSlice. All endpoints hit the DM service under two URL prefixes:

{dm_url}/api/service/platforms/{platform_key}/stripe/connect/...   ← Stripe Connect (Flow 1)
{dm_url}/api/billing/platforms/{platform_key}/...                  ← everything else

The shipped monetizationApiSlice constructs composite URLs. Direct API callers (curl, server-to-server, third-party SDKs) should prefer the canonical items/{item_unique_id}/... form — see the canonical vs composite section above.

The slice owns nine flows / 33 hooks — full hook catalog with arguments, response types, and cache tags lives in references/rtk-query-flows.md.

FlowPersonaOwning sub-skill
1 — Stripe Connect (3 hooks)Platform seller/iblai-vibe-monetization-onboard
2 — Paywall Config (4 hooks)Platform admin/iblai-vibe-monetization-configure
3 — Prices (4 hooks)Platform admin/iblai-vibe-monetization-configure
4 — Public Pricing (1 hook, skipAuth)Anonymous buyer/iblai-vibe-monetization-checkout
5 — Access Check (2 hooks, 402-as-success)Buyer/iblai-vibe-monetization-checkout
6 — Checkout (2 hooks)Buyer + guest/iblai-vibe-monetization-checkout
7 — Cancel Subscription (1 hook)Subscriber/iblai-vibe-monetization-subscription
8 — User Subscriptions (2 hooks)Subscriber/iblai-vibe-monetization-subscription
9 — Platform Analytics (4 hooks)Platform admin/iblai-vibe-monetization-analytics

Cache tags: stripeConnectStatus, paywallConfig, paywallPrices, publicPricing, accessCheck, mySubscriptions, itemSubscription, paywalls, subscribers, revenue. Mutations invalidate the relevant tags, so re-renders propagate without manual refetch() calls.

RBAC

Monetization uses three DRF permission classes — no custom RBAC decorators. The full endpoint-by-endpoint matrix is in references/rbac-matrix.md.

ClassBindApplies to
IsPlatformAdminCaller is admin on the path-scoped PlatformAll paywall config + pricing + Stripe Connect + analytics endpoints
IsEdxAuthenticatedCaller is any authenticated edX userAccess checks, authenticated checkout, my-subscriptions, subscription cancel
AllowAnyPublic, no authPublic pricing (two variants), guest checkout (two variants), checkout callback (two variants)

Two non-obvious rules:

  • request.user is the only cancel target. ItemSubscriptionCancelView uses ItemSubscription.objects.get(user=request.user, ...). There is NO admin override — an operator cannot cancel another user's subscription via this API. To force-cancel, go to Stripe directly.
  • Platform admin status is path-bound via {platform_key}. Being an admin on Platform A does not let you operate on Platform B.

The monetization skill family

SkillRoleCovers
/iblai-vibe-monetizationReference / index (this skill)Auth, schema, Platform flags, item types, RTK Query, RBAC, family map
/iblai-vibe-monetization-onboardBuild UIStripe Connect Express onboarding, status card, dashboard return, onboard-refresh, disconnect
/iblai-vibe-monetization-configureBuild UIAdmin MonetizationTab inside Account, item search, configured-items list, wizard (Item Details → Paywall → Pricing), pricing tier CRUD
/iblai-vibe-monetization-checkoutBuild UIPaywallModal + access-check (402-as-success) + Stripe checkout for authenticated buyers; public pricing + guest checkout for anonymous buy pages
/iblai-vibe-monetization-subscriptionBuild UIUser PurchasesTab inside Profile, subscription list + detail + cancel flow (portal_url vs immediate), grandfathered "Legacy" badge
/iblai-vibe-monetization-analyticsBuild UICustom analytics surfaces — revenue, Platform-wide + per-item subscribers, paywalls list. Includes the shipped CancelSubscription component (which cancels the caller's own subscription — there is no admin-on-behalf-of endpoint)
/iblai-vibe-monetization-app-paywallBuild app gateWhole-app "pay to enter" gate on the organization's OWN Stripe account (a pasted restricted key, or Connect with Stripe — OAuth, no key typed) via the DM Stripe proxy paywall endpoints — server routes + PaywallGate + /paywall pricing page, or the member's browser on their own token. No Express Connect, no commission

When in doubt, the natural sequence is:

  1. Onboard — Platform owner connects Stripe Connect (/iblai-vibe-monetization-onboard)
  2. Configure — Platform admin creates a paywall + pricing tiers for an item (/iblai-vibe-monetization-configure)
  3. Checkout — Buyer hits the paywall and pays (/iblai-vibe-monetization-checkout)
  4. Subscriptions — Subscriber views and cancels their purchases (/iblai-vibe-monetization-subscription)
  5. Analytics — Platform admin watches revenue and subscribers (/iblai-vibe-monetization-analytics)

Selling access to the whole app rather than items sits outside this sequence — it runs on the organization's own Stripe account (a pasted key, or Connect with Stripe: OAuth, not the Express onboarding above). See /iblai-vibe-monetization-app-paywall.

Important notes

Cross-cutting reminders that apply across the whole family — surface these whenever you wire up monetization in a new app.

  • Stripe Connect Express, not Standard. Platforms onboard via Express, which means commission auto-routes to the ibl.ai Platform Stripe account via destination charges + application_fee_amount. You do NOT compute or split fees in the SPA — Stripe handles it. Commission percentages surface on the Connect status response (commission_percent).
  • commission_percent 3-way divergence. Backend wire returns 4 keys (mentor, course, program, pathway). The OpenAPI schema component declares 3 (mentor, course, program — no pathway). The SDK TypeScript type declares only 2 (mentor, course). Read defensively as Record<string, number> and iterate Object.entries.
  • is_owner is schema drift. Backend wire AND SDK type include is_owner on StripeConnectStatusResponse, but the OpenAPI schema component does NOT declare it. Trust the wire.
  • Next.js required for Account + the Monetization tab. Import from @iblai/iblai-js/web-containers/next (uses next/image). PaywallModal is framework-agnostic — import from the plain @iblai/iblai-js/web-containers path.
  • Top-level exports. MonetizationTab and PaywallModal are exported at the top of @iblai/iblai-js/web-containers. PurchasesTab is NOT — it is private to the Profile shell. CancelSubscription is also NOT exported — copy from packages/web-containers/src/components/profile/monetization/cancel-subscription.tsx if you need it standalone.
  • Redux store and RTK dedup. The monetization slice is registered automatically by initializeDataLayer(). Include the standard mentorReducer and mentorMiddleware in your store. Ensure your next.config.ts carries the @reduxjs/toolkit webpack alias — duplicate RTK Query instances break tag invalidation across the monetization slice.
  • Platform flags are operator-managed. There is no in-app toggle for enable_monetization or show_paywall. Direct app authors to their ibl.ai operator when the flags need to change.
  • useCheckAccessQuery treats 402 as success. The scoped variant carries a validateStatus override so a 402 lands in data, not error. The UNSCOPED variant (useCheckAccessUnscopedQuery) does NOT carry the override — wrap it or prefer the scoped query.
  • success_url for custom buy pages. PaywallModal hardcodes success_url = cancel_url = window.location.href, which is fine because the buyer returns to the same page and the access-check cache invalidates on the webhook. For custom buy pages, choose a thank-you destination explicitly — refreshing on the buy page after success will re-hit the paywall until the webhook lands.
  • Pagination shape: schema vs runtime. The OpenAPI schema documents DRF default next/previous URI strings. The runtime paginator (StandardPageNumberPagination) overrides to next_page / previous_page integers. Trust the runtime shape.
  • MySubscriptionsParams.item_name is silently dropped. The SDK type lists item_name?: string, but the backend SubscriptionListQPSerializer only honors status, item_type, and search (filters by item_id__icontains). For text search, send search — item_name becomes a no-op.
  • Amounts ship as decimal strings. Backend DecimalField serializes amount as "15.00". The SDK type may say number for some hooks — parseFloat / Number before arithmetic.
  • The shipped CancelSubscription is self-service only. The endpoint is hard-locked to request.user. An admin cannot cancel another user's subscription via this API; route through the Stripe Dashboard for true overrides.

Common mistakes

The pitfalls that keep showing up across the family. Skim before wiring monetization into a new app — each workflow skill repeats the ones relevant to its surface.

  • Building a custom MonetizationTab. Don't. Render it indirectly via <Account authURL={...} /> — the tab strip, wizard state machine, and ?profileTab=monetization return URL contract come with the host component.
  • Forgetting authURL on the Account page. The wizard silently produces on_successful_payment: undefined, leaving Stripe Checkout with nowhere to send buyers. Always pass config.authUrl().
  • Confusing enable_monetization with show_paywall. Different products, different gates. Both MonetizationTab and PurchasesTab ride on enable_monetization. CreditBalance rides on show_paywall. See references/platform-flags.md.
  • Treating useCheckAccessQuery errors as fatal. The scoped query's validateStatus puts 402 in data, not error. Read data.has_access first. The unscoped variant does NOT carry the override.
  • Setting success_url = window.location.href on a custom buy page. Refreshing after success will re-hit the paywall until the access-check cache invalidates on webhook. Land buyers on a thank-you page or the unlocked destination instead.
  • Importing PurchasesTab or CancelSubscription directly. Neither is in the package's top-level exports. PurchasesTab is private to Profile; CancelSubscription has no exports-map entry. Use the host components or copy the source.
  • Sending item_name to my-subscriptions. The SDK type lists it; the backend silently drops it. Send search instead (filters by item_id__icontains).
  • Treating the shipped CancelSubscription as an admin override. It cancels the caller's own subscription only. No user_id argument exists. For true admin overrides, route through the Stripe Dashboard.
  • Setting currency to anything other than 'usd'. The SDK form locks it; multi-currency is not supported via the UI yet.
  • Treating amount as a number. The backend serializes DecimalField as "15.00" (string). parseFloat / Number before arithmetic; the SDK type may say number for some hooks anyway.
  • Restyling PaywallModal's grid. The grid-cols-N classes are driven by activePrices.length and tuned for up to 4 prices. Restyling breaks responsive behavior.
  • Skipping is_ready_for_payments. The admin pane already uses it to disable the Paywall section. If you build a custom buy surface, gate on it — otherwise buyers can hit a Checkout against an unfinished Stripe account and Stripe will reject the payment.
  • Ignoring the portal_url branch on cancel. Recurring cancels return { portal_url }; immediate / grandfathered cancels return the full ItemSubscriptionSerializer payload with status='canceled'. Branch on which key is present — don't assume the portal redirect.
  • Hardcoding the schema info.version. The build version rolls forward each release. Cite the URL, not the version string.
  • Claiming /buy/{paywallUniqueId} doesn't exist. The auth app ships apps/auth/app/(auth)/buy/[id]/page.tsx. The URL resolves out-of-the-box wherever authURL is set.
  • /iblai-vibe-monetization-onboard — Stripe Connect Express onboarding and status
  • /iblai-vibe-monetization-configure — admin MonetizationTab and item/pricing wizard
  • /iblai-vibe-monetization-checkout — PaywallModal, access check, Stripe checkout, guest buy
  • /iblai-vibe-monetization-subscription — user PurchasesTab and cancel flow
  • /iblai-vibe-monetization-analytics — revenue, subscribers, paywalls list
  • /iblai-vibe-monetization-app-paywall — whole-app pay-to-enter gate on the organization's own Stripe account (DM Stripe proxy; a pasted key or Connect with Stripe; no Express Connect, no commission)
  • /iblai-vibe-credit — Platform-wide credits (different product — read the "vs credits" table above)
  • /iblai-vibe-auth — Token wiring; every monetization call uses Authorization: Token <token>
  • /iblai-vibe-account — Account page that hosts the admin MonetizationTab
  • /iblai-vibe-profile — Profile page that hosts the user PurchasesTab
  • /iblai-vibe-navbar — Where to mount the CreditBalance widget (credits, not monetization)
  • /iblai-vibe-rbac — Role-management UI for assigning Platform admin
  • Brand guidelines: BRAND.md

Copyright © ibl.ai | support@iblai.zendesk.com