/iblai-vibe-monetization-checkout
Build the buyer surface: gate a page with useCheckAccessQuery, render
PaywallModal when locked, redirect to Stripe Checkout on tier select,
and ship a public/guest buy page for anonymous landing-page traffic.
When building custom UI around SDK components, use the ibl.ai brand
(primary #0058cc, button bg-gradient-to-r from-[#2563EB] to-[#93C5FD] text-white,
shadcn/ui new-york variant). Component hierarchy: ibl.ai SDK
(@iblai/iblai-js) first, then shadcn/ui (npx shadcn@latest add <c>);
do NOT write custom components when an SDK or shadcn equivalent exists.
Full brand reference: BRAND.md.
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/(browsable at{dm_url}/api/docs/;{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. 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 for the exact fetch routine.
{dm_url}+ DM token. Every checkout endpoint lives under the DM base —{dm_url}resolves to your data-manager host (e.g.https://api.iblai.app/dm); in TypeScript compose it as`${apiBase}/dm`. The auth header for the authenticated paywall flow isAuthorization: Token <DM token>— the DM token, not the AXD token. The two are different tokens issued by different services; using the AXD token against{dm_url}returns401. The SDK attaches the DM token automatically viaSERVICES.DM.
Canonical vs composite URLs. Every item-keyed endpoint exposes both a canonical (
unique_id-keyed) form and the legacy composite (platform/type/id-keyed) form. Prefer canonical for new client code — buyer routes also short-circuit cleanly on disabled paywalls in the canonical form (see "Canonical buyer 404" below). The shipped SDK hooks still build composite URLs; that is documented in each step. Full mapping in/iblai-vibe-monetization → references/api-overview.md.
Canonical buyer 404. Canonical buyer routes (
checkout,checkout-guest,items/prices/{price_unique_id}/checkout/,prices/{price_unique_id}/checkout-guest/) carryrequire_enabled_paywall = Trueand return404 {"detail": "Paywall configuration not found."}before reaching Stripe when the paywall is disabled. The composite forms do not — they delegate to the parent view and fail later with a different error.
Prerequisites
- Auth — required for the authenticated paywall + checkout flow. Wire
the DM token (not AXD) via
/iblai-vibe-auth. The public/guest buy surface in Step 5 does NOT need auth — those calls passskipAuth: true. - MCP + skills —
.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- The item has a paywall configured. A paywall config + at least one
active price must exist for
(platform, item_type, item_id). If not, the buyer flow can't run — do/iblai-vibe-monetization-configurefirst. - The Platform finished Stripe Connect Express onboarding. Checkout
hard-requires
stripe_connect_account.is_ready_for_payments. Without it,POST {dm_url}/api/billing/.../checkout/returns400 {"detail": "Platform payment system not configured"}. Do/iblai-vibe-monetization-onboardfirst.
What you'll build
- Authenticated paywall gate — page calls
useCheckAccessQueryon mount, readsdata.has_access, renders<PaywallModal>when locked, and lands the buyer back on the unlocked content after Stripe webhook reconciliation. - Public / guest buy page — standalone (no-auth) page uses
useGetPublicPricingQuery+useCreateGuestCheckoutMutationso a logged-out visitor can buy a single item with just an email. Used for landing pages, marketing emails, and shareable buy links.
Step 1: Validate the API schema
Confirm each URL, method, request body, and response shape against the live schema before you wire it. Endpoint catalogs and field tables for this skill live in:
references/access-check.md— scoped- unscoped access-check endpoints, 200 vs 402 response payloads.
references/checkout-api.md— authenticated checkout, guest checkout by item, guest checkout by price uuid, plus the callback URL the buyer returns to.references/public-pricing.md— the two public-pricing endpoints (by(type, id)and by config uuid).
When this skill and the live schema disagree, the schema wins. See
/iblai-vibe-monetization → references/schema-validation.md.
Step 2: Check access on page load
Call useCheckAccessQuery at the top of the gated page. The slice sets
validateStatus: (r) => r.ok || [402].includes(r.status) on this
endpoint, so a 402 Payment Required is delivered to data, not
error. Treating 402 as a normal error breaks the entire paywall
flow — always branch on data.has_access.
'use client';
import { useCheckAccessQuery } from '@iblai/iblai-js/data-layer';
export function GatedItemPage({ itemId, itemType }: { itemId: string; itemType: string }) {
const platformKey = process.env.NEXT_PUBLIC_PLATFORM_KEY!;
const { data, isLoading } = useCheckAccessQuery({
platform_key: platformKey,
item_type: itemType,
item_id: itemId,
});
if (isLoading || !data) return <PageSkeleton />;
if (!data.has_access && data.pricing) {
return <PaywallScreen pricing={data.pricing} platformKey={platformKey} itemId={itemId} itemType={itemType} />;
}
return <UnlockedContent itemType={itemType} itemId={itemId} />;
}
Payload from GET {dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/access-check/ (same shape for 200 and 402):
{
"has_access": false,
"item_type": "mentor",
"item_id": "my-mentor-slug",
"reason": "no_subscription",
"requires_payment": true,
"pricing_available": true,
"pricing": {
"item_name": "Pro Mentor",
"prices": [
{ "id": "<price-uuid>", "name": "Monthly", "amount": "15.00",
"currency": "usd", "interval": "month", "is_active": true,
"features": ["Unlimited chat"], "stripe_price_id": "price_..." }
]
},
"subscription": null
}
reason is informational. Bare values: "no_subscription",
"no_paywall", "item_not_found", "paywall_disabled". For an active
sub whose status is in the access whitelist (active/free/grandfathered/trialing),
reason is the bare status. For other statuses it is prefixed:
"subscription_canceled", "subscription_past_due", "subscription_incomplete".
Gate on has_access; use pricing to populate the modal.
Step 3: Render the locked state with PaywallModal
PaywallModal is exported top-level from
@iblai/iblai-js/web-containers and ships its own grid, copy, and
Pay button. Pass it the pricing block straight off the access-check
response — do NOT restyle it, do NOT swap the grid, do NOT inject your
own price cards.
'use client';
import { useState } from 'react';
import { PaywallModal } from '@iblai/iblai-js/web-containers';
import type { AccessCheckResponse } from '@iblai/iblai-js/data-layer';
interface Props {
pricing: NonNullable<AccessCheckResponse['pricing']>;
platformKey: string;
itemId: string;
itemType: string;
hard?: boolean;
}
export function PaywallScreen({ pricing, platformKey, itemId, itemType, hard }: Props) {
const [open, setOpen] = useState(true);
return (
<>
<LockedPlaceholder => setOpen(true)} />
<PaywallModal
pricing={pricing}
platformKey={platformKey}
itemId={itemId}
itemType={itemType}
open={open}
=> setOpen(false)}
closable={!hard}
/>
</>
);
}
Props (verbatim from the SDK):
| Prop | Type | Notes |
|---|---|---|
pricing |
{ item_name; prices: PaywallPrice[] } |
Copy directly from accessCheck.data.pricing. |
platformKey |
string |
Must match the token's Platform. |
itemId |
string |
The item being gated. |
itemType |
string |
Normalized type (mentor, course, program, ...). |
open |
boolean |
Controlled by the parent. |
onClose |
() => void |
Fires when the user dismisses (only when closable). |
closable? |
boolean (default true) |
false = hard paywall: hides the close button and intercepts escape + outside-click. |
buttonClassName? |
string |
Pass-through Tailwind classes for the Pay button only. Do not restyle anything else. |
What the modal does internally — it calls
useCreateCheckoutMutation({ platform_key, item_type, item_id, price_id, success_url, cancel_url })
when the buyer clicks Pay, then runs
window.location.href = result.checkout_url to send them to Stripe.
The mutation hits POST {dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/checkout/
and returns { checkout_url, session_id, platform_key } (the backend
CheckoutSessionResponseSerializer returns all three; the SDK's TS
CheckoutResponse type is missing platform_key — SDK drift, the
runtime field is present). Stripe drops the buyer back
at {dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/checkout-callback/
on success, where the server reconciles the session before redirecting
to the buyer-facing URL.
Hard paywall vs soft paywall. A soft paywall (closable={true},
the default) lets the buyer dismiss the modal and stay on the page —
fine when the surrounding page still has useful free content. A hard
paywall (closable={false}) hides the close button, intercepts escape,
and ignores outside-click; pair it with a full-page lock screen so the
buyer can't navigate back. Pick one per item.
Step 4: Customize success / cancel URLs
The modal hardcodes success_url = cancel_url = window.location.href.
That works for in-page gates (your access-check re-runs after the
webhook lands and the page flips to unlocked) but it is wrong for
standalone buy pages — buyers who refresh after a successful payment
will land back on the same buy page and re-enter the paywall flow until
the access-check cache invalidates.
For a custom buy page, do NOT use PaywallModal. Wire
useCreateCheckoutMutation (or its guest sibling in Step 5) directly so
you can pass deliberate URLs:
import { useCreateCheckoutMutation } from '@iblai/iblai-js/data-layer';
const [createCheckout, { isLoading }] = useCreateCheckoutMutation();
async function handleBuy(priceId: string) {
const result = await createCheckout({
platform_key: platformKey,
item_type: itemType,
item_id: itemId,
price_id: priceId,
success_url: 'https://example.com/welcome?item=' + itemId,
cancel_url: 'https://example.com/pricing',
}).unwrap();
window.location.href = result.checkout_url;
}
Land the buyer on the unlocked destination (/learn/{itemId}) or a
thank-you page that itself links back to the unlocked content. Never
point success_url at the buy page.
Step 5: Public / guest buy page
A logged-out visitor can buy a single item: fetch public pricing (…/pricing/, no auth), create a guest checkout session (…/checkout-guest/), hand off to Stripe, return through the callback. Both calls, the page code, and the error table are in references/guest-buy.md.
Step 6: Unscoped access check (for cross-Platform embeds)
When your SPA has no Platform context — e.g. an embeddable widget that floats over arbitrary host pages — call the unscoped variant:
import { useCheckAccessUnscopedQuery } from '@iblai/iblai-js/data-layer';
const { data } = useCheckAccessUnscopedQuery({
item_type: itemType,
item_id: itemId,
platform_key: platformKey, // passed as a query string, not a path segment
});
The hook hits GET {dm_url}/api/billing/access-check/{item_type}/{item_id}/?platform_key=<key>.
The response shape and 200-vs-402 contract are identical on the wire,
but unlike useCheckAccessQuery, useCheckAccessUnscopedQuery does
NOT carry a validateStatus override — a 402 lands in error, not
data. Either wrap it with your own validateStatus or use the scoped
variant whenever the Platform is known. Prefer the scoped variant
anyway — it keeps the URL self-describing for logging / caching.
Verify
Run /iblai-vibe-ops-test before telling the user the work is ready:
pnpm build— must pass with zero errors.pnpm devand exercise both surfaces:pnpm dev & npx playwright screenshot http://localhost:3000/learn/<locked-item> /tmp/paywall.png npx playwright screenshot http://localhost:3000/buy/<item-id> /tmp/guest.png- Authenticated paywall: locked item renders
<PaywallModal>(Choose your planheader visible); picking a tier redirects tohttps://checkout.stripe.com/...;closable={false}makes escape and outside-click no-ops. - Custom success URL: refreshing the success page does NOT bounce back into the paywall.
- Guest buy page: loads with no token (private window);
useGetPublicPricingQueryreturns 200 withis_paywalled: true; submitting email + tier redirects to Stripe.
Common mistakes
- Treating
useCheckAccessQueryerrors as fatal. 402 arrives indata, noterror.if (error) return <Crash />kills the paywall. - Pointing
success_urlat the same buy page. Refresh re-enters the paywall flow until the cache invalidates. Land on the unlocked destination or a thank-you page. - Using a
currencyother than'usd'. Stripe Connect locks to USD; other values reject server-side. - Restyling the modal grid.
PaywallModalships its own grid. UsebuttonClassNamefor the Pay button only. - Skipping
is_ready_for_payments. Unfinished Connect onboarding 400s the checkout with"Platform payment system not configured"(see/iblai-vibe-monetization-onboard). - Going straight to Stripe.js. Bypasses Connect account signing, ibl.ai commission, and buyer-subscription binding — breaks reconciliation.
MCP tools for further detail
get_component_info("PaywallModal")
get_hook_info("useCheckAccessQuery")
get_hook_info("useCheckAccessUnscopedQuery")
get_hook_info("useGetPublicPricingQuery")
get_hook_info("useCreateCheckoutMutation")
get_hook_info("useCreateGuestCheckoutMutation")
Files in this skill's scope
Frontend SDK (iblai/ibl-web-frontend):
packages/web-containers/src/components/modals/paywall-modal.tsx— the exportedPaywallModal(props, hard-paywall escape/outside-click guards, internaluseCreateCheckoutMutation+ redirect).packages/data-layer/src/features/monetization/custom-api-slice.ts— specifically thegetPublicPricing,checkAccess,checkAccessUnscoped,createCheckout,createGuestCheckoutendpoints, plus thevalidateStatusoverride that lets 402 land indataoncheckAccess.packages/data-layer/src/features/monetization/paywall-utils.ts—displayItemType,slugify,useDebounce,getAuthURLExtension,buildOnSuccessfulPaymentUrl.
Backend (web/ibl-dm-core-apps/ibl-dm-billing-app/billing/views.py):
ItemAccessCheckViewatbilling/views.py:668-764(unscoped,IsEdxAuthenticated, returns 402 with pricing when locked).ScopedItemAccessCheckViewatbilling/views.py:767.ItemCheckoutViewatbilling/views.py:1556(authenticated,IsEdxAuthenticated).ItemGuestCheckoutViewatbilling/views.py:1751(AllowAny, email required).ItemGuestCheckoutByPriceViewatbilling/views.py:1804(AllowAny, takes price uuid).PublicItemPricingViewatbilling/views.py:1958(AllowAny).ItemCheckoutCallbackViewatbilling/views.py:2257(Stripe return URL — the server reconciles the session before redirecting the buyer).billing/services/item_paywall.py:54(ItemPaywallService.check_access) — the decision tree behind the 402: subscription lookup, status whitelist,reasonselection, pricing block assembly.billing/services/payment_access.py:202(check_payment_access) — the cache layer in front of access checks; TTL constantsPAYMENT_ACCESS_CACHE_TTL_GRANT = 300(5 min) andPAYMENT_ACCESS_CACHE_TTL_DENY = 60(1 min).
Related skills
/iblai-vibe-monetization— family index, auth, schema validation, item-type normalization, RBAC matrix./iblai-vibe-monetization-configure— prerequisite: enable a paywall and create at least one active price./iblai-vibe-monetization-onboard— prerequisite: Connect Express onboarding sois_ready_for_paymentsis true./iblai-vibe-monetization-subscription— post-purchasePurchasesTablist + cancel flow./iblai-vibe-monetization-analytics— admin analytics (revenue, subscribers, conversion)./iblai-vibe-rbac— RBAC role assignment for admin-side analytics, subscription mgmt, and Connect onboarding./iblai-vibe-auth— token wiring; reuse the same token store. Do not introduce a parallel auth layer.- Brand guidelines: BRAND.md