# Iblai Vibe Monetization Configure

> Build the admin MonetizationTab inside the Account page — the Platform seller wizard that turns an agent/course/program/pathway/custom item into a paid product, with paywall settings (grandfathering, free tier, trial days) and per-item pricing tiers. Use when the user mentions monetization tab, paywall configuration, item pricing, pricing tiers, wizard, custom items, grandfathering, item search, or "configure a paywall". See /iblai-vibe-monetization for the family index, /iblai-vibe-monetization-onboard for the Stripe Connect prerequisite, /iblai-vibe-monetization-checkout for what buyers see, /iblai-vibe-monetization-subscription for the user purchases pane, /iblai-vibe-monetization-analytics for revenue, /iblai-vibe-account for the host page, /iblai-vibe-rbac for the can_sell_items gate, /iblai-vibe-auth for token wiring.

- Skill: `iblai/iblai-vibe-monetization-configure` (Agent Skill, multi-file: 9 files)
- Install (CLI): `npx skillmds@latest add iblai/iblai-vibe-monetization-configure`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iblai/iblai-vibe-monetization-configure/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: iblai (https://skillmd.com/u/iblai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iblai/iblai-vibe-monetization-configure

---


# /iblai-vibe-monetization-configure

Build the admin **MonetizationTab** — the Platform seller surface that
lives inside the **Account** page. A Platform admin uses it to pick an
existing item (an agent, a course, a program, a pathway) or create a
custom item, then walks a wizard that enables the paywall and adds
pricing tiers. The actual UI ships as a default-exported SDK component;
your job is to host it correctly and pass the right props.

> **Common setup (brand, conventions, env files, verification):** see [docs/skill-setup.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/docs/skill-setup.md).

> **Verify the API before you call it.** Fetch the live OpenAPI schema at `{dm_url}/api/docs/schema/` (browsable at `{dm_url}/api/docs/`; `{dm_url}` = `https://api.$DOMAIN/dm`, `DOMAIN` from `iblai.env`, default `iblai.app`) and confirm the URL path, method, request body, and response shape for every endpoint you reach for. The schema is the source of truth; the URLs in this skill exist for orientation and may drift between releases. See [/iblai-vibe-monetization → references/schema-validation.md](../../billing/iblai-vibe-monetization/references/schema-validation.md) for the exact fetch routine.

![Admin Monetization tab inside the Account page](./admin-monetization-tab.png)

## Prerequisites

- Auth must be set up first (`/iblai-vibe-auth`) — reuse the same token wiring.
- `.mcp.json` configured with `@iblai/mcp` (and skills installed via
  `npx skills add iblai/vibe --all`).
- `iblai.env` populated with `PLATFORM`, `DOMAIN`, `TOKEN`. If missing:
  `curl -o iblai.env https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/iblai.env`
- The **Account** page is already wired up (`/iblai-vibe-account`). The
  `MonetizationTab` is mounted by the SDK's `Account` component once the
  flag below is on — do NOT build a parallel host.
- The caller's Platform record has `enable_monetization === true`. This
  is set by an ibl.ai operator on the `tenants` array surfaced via
  `localStorage`; there is no in-app toggle. Without it, the
  `Monetization` tab is hidden in the Account sidebar.
- The caller has the `can_sell_items` RBAC permission on the Platform
  (`/platforms/{platformKey}/#can_sell_items`). The Account component
  resolves this via `checkRbacPermission` — see `/iblai-vibe-rbac`.
- **Stripe Connect Express onboarding is complete** for the Platform.
  The Connect status response must report `is_ready_for_payments: true`.
  If it is not, the paywall pane shows a dashed "Connect Stripe first"
  card and every price-create call returns 400. Do
  `/iblai-vibe-monetization-onboard` first and come back.

![Monetization pane before Stripe Connect onboarding — dashed "Connect Stripe first" card](./admin-monetization-sample-no-onboarding.png)

## What you'll build

You are not building a UI from scratch — the SDK's `MonetizationTab`
already renders everything below. You are wiring it into the Account
page and exposing a URL the Connect onboard return can land on.

| Section | What renders | Screenshot |
|---|---|---|
| Stripe Connect status card | Status badge, business info, connect / dashboard button | (covered by `/iblai-vibe-monetization-onboard`) |
| Paywall config (disabled state) | Dashed-border card: "Connect Stripe first to configure paywalls" | [admin-monetization-sample-no-onboarding.png](./admin-monetization-sample-no-onboarding.png) |
| Item search dropdown | Debounced 300 ms search across agents, courses, programs + a `+` button for custom items | [admin-monetization-sample-pricing-list.png](./admin-monetization-sample-pricing-list.png) |
| Configured-items list | Paginated cards (8 per page), filter `All / Active / Disabled`, badge per item type | [admin-monetization-sample-pricing-list.png](./admin-monetization-sample-pricing-list.png) |
| Item detail wizard | 2-step (existing) or 3-step (custom) step indicator | [admin-monetization-sample-paywall-config.png](./admin-monetization-sample-paywall-config.png) |
| Paywall settings | `is_enabled`, `allow_free_tier`, `trial_period_days`, `grandfathering_strategy`, `on_successful_payment` | [admin-monetization-sample-paywall-config.png](./admin-monetization-sample-paywall-config.png) |
| Pricing tiers | CRUD list with create-form, edit-in-place, delete | [admin-monetization-sample-pricing-config.png](./admin-monetization-sample-pricing-config.png) |

## Step 1: Validate the API schema

Before writing a single fetcher, run the schema-fetch routine from the
overview skill and confirm the three endpoints this skill exercises.
The SDK's mutations wrap them, but you should know the shape:

All endpoints below 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 AXD token returns `401`).

| Capability | Canonical (recommended) | Composite (legacy) |
|---|---|---|
| Paywall config CRUD | `GET / PUT / DELETE {dm_url}/api/billing/items/{item_unique_id}/paywall/` | `GET / POST / PUT / DELETE {dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/paywall/` |
| Prices list / create | `GET / POST {dm_url}/api/billing/items/{item_unique_id}/paywall/prices/` | `GET / POST {dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/paywall/prices/` |
| Price detail | `GET / PUT / DELETE {dm_url}/api/billing/items/prices/{price_unique_id}/` | `GET / PUT / DELETE {dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/paywall/prices/{price_id}/` |

**Create-first lifecycle.** The canonical URL needs `item_unique_id`,
which only exists *after* the paywall config has been created. For the
**initial `POST` / `PUT`** that creates the paywall, use the composite
URL — once the response comes back with `unique_id`, switch to canonical
for all subsequent reads / updates / deletes. New price rows have the
same lifecycle: create via composite or canonical list endpoint, then
operate on the returned `unique_id` via `items/prices/{price_unique_id}/`.

**Canonical price-detail 404 conditions.** The canonical price-detail
route resolves only **active, non-deleted prices on enabled paywalls** —
inactive (`is_active=false`), soft-deleted (`is_deleted=true`), or
disabled-paywall prices return `404 NotFound`. The composite form has
no such gate and reaches the parent view. Use canonical for buyer
flows; use composite when you need to read or update a row that is
intentionally inactive.

All endpoints require `IsPlatformAdmin`. The schemas for the request body,
response, and the enum values for `grandfathering_strategy` and
`interval` are pinned in:

- [`references/paywall-config-api.md`](./references/paywall-config-api.md)
- [`references/prices-api.md`](./references/prices-api.md)
- [`references/wizard-state-machine.md`](./references/wizard-state-machine.md)

If those files are not present in this directory, the canonical schema
is the single source of truth — `grep "/paywall/" /tmp/iblai_schema.yaml`
will surface every path, method, and `$ref` body shape.

## Step 2: Confirm Account page integration

The Account page (`/iblai-vibe-account`) is the host. It owns the sidebar
that gates the `Monetization` tab on `currentTenant?.enable_monetization
&& checkRbacPermission(rbacPermissions, '/platforms/{platformKey}/#can_sell_items')`,
and it mounts `MonetizationTab` directly — passing `platformKey` and
`authURL`. `authURL` is load-bearing: the SDK uses it to build the
Stripe Connect onboarding return URL **and** to derive the domain
extension when generating the default `on_successful_payment` redirect
for an item. Do not pass an empty string and do not strip the trailing
domain.

A typical Next.js host that supplies all the required props:

```tsx
// app/account/page.tsx
'use client';

import { Account } from '@iblai/iblai-js/web-containers/next';
import { useAuth } from '@/lib/iblai/auth';
import config from '@/lib/iblai/config';

export default function AccountPage() {
  const { user, tenants, rbacPermissions } = useAuth();
  if (!user) return null;

  return (
    <Account
      tenant={tenantKey}
      tenants={tenants}
      username={user.username}
      email={user.email}
      mainPlatformKey={config.mainTenantKey()}
      authURL={config.authUrl()}
      enableRbac={true}
      rbacPermissions={rbacPermissions}
      isAdmin={user.is_admin}
      onInviteClick={() => {}}
      onClose={() => {}}
    />
  );
}
```

`config.authUrl()` resolves to the same auth host (e.g.
`https://auth.{ext}`) that `/iblai-vibe-auth` already configured. Do not
introduce a parallel value. If your Platform's `authURL` is
`https://auth.example.com`, the SDK's `getAuthURLExtension` strips
`auth` and yields `example.com`, which is then used to build defaults
like `https://mentorai.example.com/platform/{platformKey}/{itemId}` for the
post-payment redirect.

## Step 3: Reach the Monetization pane from a URL

The Account component reads a query-string hint to pre-select a tab.
The convention is `?profileTab=monetization` — the same query param
that the Stripe Connect onboarding flow uses as its **return URL** so
that after a Connect redirect the user lands back on this exact pane.

Two things follow from this:

1. **Your router must preserve the query string.** A blanket
   `router.replace(pathname)` in a layout or middleware will strip
   `profileTab` and the user will land on the default `organization`
   tab after Connect onboarding completes.
2. **You should plumb the hint into `targetTab`** so direct loads of
   `/account?profileTab=monetization` open the pane immediately:

```tsx
import { useSearchParams } from 'next/navigation';

const params = useSearchParams();
const targetTab = params.get('profileTab') ?? 'basic';

return <Account targetTab={targetTab} /* ...other props */ />;
```

The Account component also fires `onTabChange('monetization')` when the
user clicks the tab — you can mirror that to the URL with
`router.replace` so refreshes stay sticky.

## Step 4: Configure a paywall for an existing item

![Configured-items list with the item search dropdown and filter tabs](./admin-monetization-sample-pricing-list.png)

For an agent, course, or program that already exists in the catalog,
the wizard has **two steps**: `Paywall → Pricing`. The flow is:

1. The admin types into the **item search dropdown**. The SDK debounces
   for 300 ms, then fans out to two queries in parallel:
   - `useGetAiSearchMentorsQuery` — agents.
   - `useGetPersonnalizedSearchQuery` — courses and programs.
2. The admin picks a result. The SDK resolves it to
   `{ item_type, item_id, item_name }` (e.g. `mentor` + the agent's
   `unique_id`).
3. The wizard calls `useGetPaywallConfigQuery` — if no row exists, the
   backend returns the default `is_enabled: false` skeleton.
4. The admin sets `is_enabled`, `allow_free_tier`, `trial_period_days`,
   `grandfathering_strategy`, and `on_successful_payment`, then submits.
5. The SDK calls `useEnablePaywallMutation` on first save (creates the
   `ItemPaywallConfig`, provisions a Stripe product on the connected
   account, stamps `paywall_enabled_at`), or
   `useUpdatePaywallMutation` on every subsequent save.

![Paywall configuration step](./admin-monetization-sample-paywall-config.png)

### Grandfathering — pick the right strategy

`grandfathering_strategy` decides what happens to users who already
have access to the item the moment the paywall flips on:

| Value | Effect on existing users |
|---|---|
| `free_forever` | Existing users keep unlimited free access. Only new users hit the paywall. |
| `require_subscription` | Everyone must subscribe (no grandfathering). |

The backend stamps `paywall_enabled_at` on the first enable — that is
the cutoff used to decide who counts as "existing." Switching strategies
later does NOT re-stamp the cutoff. Surface this to the admin: once you
ship a paywall as `free_forever`, the population of grandfathered users
is frozen at that instant.

## Step 5: Configure a paywall for a custom item

A custom item is anything that is not a managed agent / course /
program / pathway — for example, an external SaaS subscription you
want to sell through Stripe. The wizard has **three steps**:
`Item Details → Paywall → Pricing`.

The admin clicks the `+` button next to the search dropdown to enter
custom mode. They fill:

- **Item type** (free text, e.g. "Workshop", "API key")
- **Item name** (e.g. "Advanced Prompt Engineering 2026")
- **Description** (optional, becomes the Stripe checkout description)
- **Product URL** (optional, used to build `on_successful_payment`)

On `Create`, the SDK slugifies both fields with `slugify()` from
`paywall-utils.ts` (lowercased, alphanumerics + `-`, no leading or
trailing dashes) and posts to the paywall create endpoint with:

```ts
{
  item_type: slugify(customItemType),  // e.g. "workshop"
  item_id:   slugify(customItemName),  // e.g. "advanced-prompt-engineering-2026"
  is_enabled: false,
  allow_free_tier: false,
  trial_period_days: 0,
  grandfathering_strategy: 'free_forever',
  item_name: customItemName,
  description: customDescription,
  on_successful_payment: buildOnSuccessfulPaymentUrl({ /* ... */ }),
}
```

The deliberate `is_enabled: false` is so the item exists but stays
dormant until the admin completes the Paywall step. After the create
returns, the wizard advances to `paywall`, then `pricing`. The same
`useEnablePaywallMutation` is reused for the dormant create and the
final enable — the backend uses `is_enabled` change tracking to decide
whether to provision the Stripe product.

`buildOnSuccessfulPaymentUrl` from `paywall-utils.ts` defaults the
post-payment redirect by extracting the domain extension from `authURL`
(e.g. `https://auth.iblai.app` → `iblai.app`) and building a per-type
URL:

| `item_type` | Default `on_successful_payment` |
|---|---|
| `mentor` / `agent` | `https://mentorai.{ext}/platform/{platformKey}/{itemId}` |
| `course` | `https://skillsai.{ext}/courses/{itemId}` |
| `program` | `https://skillsai.{ext}/programs/{itemId}` |
| `pathway` | `undefined` — no default branch in `buildOnSuccessfulPaymentUrl`. Admins must set `on_successful_payment` manually on the paywall config or new pathway purchases will land with no redirect. |
| custom (anything else) | The admin-supplied `customProductUrl`, or `undefined` |

This is why passing the correct `authURL` to `Account` matters — pass a
bogus value and every default redirect ends up on the wrong subdomain.

## Step 6: Manage pricing tiers

Once the paywall is enabled, the wizard advances to the **Pricing** step.
The SDK's `PriceManagement` component owns the tier list and form.

![Pricing configuration step](./admin-monetization-sample-pricing-config.png)

Hooks the SDK uses (all under `IsPlatformAdmin`):

- `useListPricesQuery` → `GET /paywall/prices/`
- `useCreatePriceMutation` → `POST /paywall/prices/`
- `useUpdatePriceMutation` → `PUT /paywall/prices/{price_id}/`
- `useDeletePriceMutation` → `DELETE /paywall/prices/{price_id}/` (soft delete)

The price form collects:

| Field | Type | Notes |
|---|---|---|
| `name` | string (max 255) | Display label — e.g. "Basic", "Premium". |
| `amount` | decimal string | USD, pattern `^-?\d{0,8}(?:\.\d{0,2})?$`. `0` means free. |
| `currency` | enum | Locked to `usd` in the SDK's `EMPTY_FORM`. Do NOT change this. |
| `interval` | enum | `month` / `year` / `one_time`. |
| `description` | nullable string | Tier description. |
| `features` | array of strings | Chip input — `Enter` adds, `X` removes. JSON-serialized in storage. |
| `remark` | string (max 100) | Optional badge like "Most Popular". |
| `is_active` | bool | Inactive prices stay listed but cannot be purchased. |
| `sort_order` | int | Display order; defaults to `0`. |

Two behaviors worth surfacing:

1. **Pricing-field edits create a new Stripe price.** The backend's
   `PUT /paywall/prices/{price_id}/` description states: "If pricing
   fields change and a Stripe price exists, a new Stripe price is
   created and the old one deactivated." Existing subscriptions keep
   their original Stripe price — only new checkouts hit the new one.
2. **Delete is a soft delete.** The DELETE handler sets `is_deleted=true`
   and deactivates the matching Stripe price; historical analytics
   keep the row. Do not show the price as "gone" — show it as "archived".

A `POST /paywall/prices/` is rejected (400) if the parent paywall is
disabled or the Connect account is not ready. Surface that as a toast
that says "Enable the paywall first" rather than a raw error.

## Step 7: Copy the public buy URL

Click the copy icon next to the item title to copy
`${authURL}/buy/{paywallUniqueId}` to the clipboard — the SDK toasts
"URL copied to clipboard" after writing it. This URL resolves
**out-of-the-box** because the auth app ships a `/buy/[id]` route at
`apps/auth/app/(auth)/buy/[id]/page.tsx` in the `iblai/ibl-web-frontend`
monorepo. Since `authURL` already points at the auth app, the URL
renders the public buy page with zero additional wiring on your end.

If you are NOT deploying the auth app (e.g. you're hosting only the
admin shell on a standalone domain), you'll need to build your own buy
page using `useGetPublicPricingQuery({ config_unique_id })` and
`useCreateGuestCheckoutMutation` — see the public/guest buy section in
`/iblai-vibe-monetization-checkout` for the complete recipe.

## Verify

Run `/iblai-vibe-ops-test` before telling the user the work is ready:

1. `pnpm build` — must pass with zero errors.
2. `pnpm dev` and drive the tab:
   ```bash
   pnpm dev &
   npx playwright screenshot \
     "http://localhost:3000/account?profileTab=monetization" \
     /tmp/monetization.png
   ```
3. Confirm with the screenshot:
   - The `Monetization` tab is present in the sidebar (gating works).
   - Without Connect onboarding, the paywall pane shows the dashed
     "Connect Stripe first" card (Stripe Connect status guards correctly).
   - With Connect onboarding done, the item search dropdown is enabled
     and typing fires the debounced 300 ms search.
4. Configure a real item end-to-end: enable paywall, save, add a `$9.99`
   monthly tier, click `Save`. Then refresh the page and confirm the
   tier persists.
5. Click the `+` button, fill `customItemType="workshop"`,
   `customItemName="Test Workshop"`, click `Create` — the wizard should
   advance to step 2 with the slugified id `test-workshop`.

## Common mistakes

- **Not passing `authURL`.** The SDK uses it to derive the post-payment
  redirect default; passing `""` or a non-domain string produces broken
  defaults like `https://mentorai./platform/{platformKey}/{itemId}` and the
  copy-URL feature silently breaks.
- **Hand-rolling a custom `MonetizationTab`.** It is now a top-level
  named export from `@iblai/iblai-js` (`packages/web-containers` ships
  `MonetizationTab`), but you should still NOT mount it standalone —
  the Account page owns the sidebar, the tab gating, and the
  `onTabChange` plumbing.
- **Trusting older docs that said "MonetizationTab is not exported".**
  It is exported. Use the version surfaced via `Account`.
- **Confusing `enable_monetization` with `show_paywall`.**
  `enable_monetization` is the Account/Monetization-pane gate.
  `show_paywall` is the credit-system gate covered by `/iblai-vibe-credit`.
  They are not synonyms — see `/iblai-vibe-monetization` for the matrix.
- **Locking currency to anything other than `usd`.** The SDK's
  `EMPTY_FORM` hard-codes `currency: 'usd'`, and the connected Stripe
  account is provisioned for USD. Editing the form to pass `eur` will
  be accepted by the schema but rejected at checkout.
- **Skipping the `is_ready_for_payments` gate.** Calling
  `useCreatePriceMutation` before Connect onboarding completes returns
  400 with "Stripe Connect account not ready" — surface that as a
  blocking toast, do not retry.
- **Stripping `?profileTab=monetization` in a middleware/layout.** The
  Stripe Connect onboard return URL relies on this param to land the
  admin back on the Monetization pane.

## MCP tools for further detail

When you need exact prop signatures or hook shapes, query the MCP
instead of guessing:

- `get_component_info("Account")` — host props, sidebar gating logic.
- `get_component_info("MonetizationTab")` — `platformKey`, `authURL`.
- `get_hook_info("useGetPaywallConfigQuery")` — request/response shape.
- `get_hook_info("useEnablePaywallMutation")` — body shape, error model.
- `get_hook_info("useUpdatePaywallMutation")` — body shape, idempotency.
- `get_hook_info("useListPricesQuery")` — response array shape.
- `get_hook_info("useCreatePriceMutation")` — `ItemPriceCreate` body.
- `get_hook_info("useUpdatePriceMutation")` — Stripe re-issue behavior.
- `get_hook_info("useDeletePriceMutation")` — soft-delete semantics.
- `get_hook_info("useListPaywallsQuery")` — pagination envelope.
- `get_hook_info("useGetAiSearchMentorsQuery")` — agent search shape.
- `get_hook_info("useGetPersonnalizedSearchQuery")` — course/program shape.

## Files in this skill's scope

Frontend (SDK, read-only — do not fork these into your host):

- `packages/web-containers/src/components/profile/monetization/index.tsx`
- `packages/web-containers/src/components/profile/monetization/paywall-config.tsx`
- `packages/web-containers/src/components/profile/monetization/paywalled-items-list.tsx`
- `packages/web-containers/src/components/profile/monetization/item-search-dropdown.tsx`
- `packages/web-containers/src/components/profile/monetization/paywall-detail.tsx`
- `packages/web-containers/src/components/profile/monetization/price-management.tsx`
- `packages/web-containers/src/components/profile/monetization/wizard-step-indicator.tsx`
- `packages/web-containers/src/components/profile/monetization/paywall-utils.ts`

Backend (for reference — RBAC, validation, Stripe-product sync):

- `ItemPaywallConfigView` at `web/ibl-dm-core-apps/ibl-dm-billing-app/billing/views.py:998`
- `ItemPriceListView` at `web/ibl-dm-core-apps/ibl-dm-billing-app/billing/views.py:1183`
- `ItemPriceDetailView` at `web/ibl-dm-core-apps/ibl-dm-billing-app/billing/views.py:1300`

## Related skills

- `/iblai-vibe-monetization` — Family index, schema validation, RTK Query overview, RBAC matrix.
- `/iblai-vibe-monetization-onboard` — **Prerequisite.** Stripe Connect Express onboarding, `is_ready_for_payments` gate, return URL using `?profileTab=monetization`.
- `/iblai-vibe-monetization-checkout` — What buyers see: `PaywallModal`, `useCheckAccessQuery`, checkout, guest checkout, public pricing (`useGetPublicItemPricingQuery`).
- `/iblai-vibe-monetization-subscription` — End-user `PurchasesTab` + cancel flow.
- `/iblai-vibe-monetization-analytics` — Revenue, subscribers, paywalls list, admin cancel tool.
- `/iblai-vibe-account` — Hosts the `MonetizationTab`; owns the sidebar and gating.
- `/iblai-vibe-auth` — Token wiring; every paywall and price call uses `Authorization: Token <token>`.
- `/iblai-vibe-rbac` — `can_sell_items` permission on `/platforms/{platformKey}/`.
- **Brand guidelines**: [BRAND.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/BRAND.md)

