# Crediball — integration guide for AI coding agents Crediball turns the actions an AI app performs into credits-based monetization. The whole integration is: init once, then wrap each valuable action. Follow these rules exactly. ## Paste-ready prompt for the developer Add Crediball to my app. My API key is cb_live_... (the base URL defaults to the hosted API — no need to set it). Initialize once with crediball.init, then wrap each valuable action with crediball.action("", userId, () => doTheWork()), and show a paywall on InsufficientCreditsError. Also wrap my AI client(s) with @crediball/sdk/ai (wrapOpenAI / wrapAnthropic / wrapGemini / crediballMiddleware) so model usage shows up on the dashboard's Models page — always call the wrapped model INSIDE the crediball.action() callback (or runWith(...) for the same event) so it enriches the action instead of billing its own phantom action under the raw model id. When you're done wrapping, run "npx @crediball/sdk sync" so every action you wrapped shows up on my dashboard right away instead of only after someone triggers it. Follow the conventions in this file. ## Golden rules 1. BILL THE ACTION, NOT THE AI CALL. Do crediball.init(apiKey) once, then wrap each valuable action with crediball.action(action, userId, fn) — it registers the action, deducts credits before fn runs, and returns fn's result. (Use crediball.track(action, userId) when there's no result to wrap.) Do NOT define actions or prices in code. 2. NEVER hardcode credit amounts. Costs (costCredits) live in the Crediball dashboard and can change without a code change. Crediball auto-discovers every action you run — and "npx @crediball/sdk sync" registers the ones you wrapped but haven't run yet. 3. The action KEY is a STABLE identifier. Don't rename keys casually — a new key is a new action. Change display labels in the dashboard. 4. Run the SDK on the SERVER only. Never expose the API key to the browser. 5. The end user must NEVER see Crediball branding or log into Crediball. ## Sandbox vs live - Every app starts in SANDBOX: every action is recorded, nothing is billed, no setup required. Unknown actions are accepted automatically and appear in the dashboard. - The developer activates actions + sets credit costs in the dashboard, then flips the app to LIVE in one click. In live mode, running an ACTIVATED action consumes its credits from the given user (and returns 402 when they're out). ## Setup Install (always pull the latest — the dashboard nudges apps on an old SDK): npm install @crediball/sdk@latest (server: tracking) npm install @crediball/react@latest (client: optional paywall + credit UI, React) npm install @crediball/elements@latest (client: same credit UI as web components — any framework, or none) Environment variables — THREE, and where each is available matters. Set them ALL in your host's project settings (e.g. Vercel/Netlify env vars), not just a local .env, then redeploy: SERVER-ONLY (never exposed to the browser — these carry the SECRET key): CREDIBALL_API_KEY = cb_live_... (secret; server metering — never ship to browser/git) CREDIBALL_API_URL = https://www.crediball.ai/api BROWSER-EXPOSED (the PUBLISHABLE key, cb_pub_..., is browser-safe; the paywall + credit UI run client-side and need it). It MUST carry your framework's public-env prefix or it is undefined in the browser — the #1 reason a paywall renders empty (no packages, dead buttons): Next.js: NEXT_PUBLIC_CREDIBALL_PUBLISHABLE_KEY = cb_pub_... Vite: VITE_CREDIBALL_PUBLISHABLE_KEY = cb_pub_... Create React App: REACT_APP_CREDIBALL_PUBLISHABLE_KEY = cb_pub_... (The publishable key is browser-safe, so hardcoding it in the client is fine too — but an env var without the public prefix silently resolves to undefined.) Initialize (server-side, once): import crediball from "@crediball/sdk"; crediball.init(process.env.CREDIBALL_API_KEY, { baseUrl: "https://www.crediball.ai/api" }); // cb_live_... (keep secret) ## Wrap every meaningful action Wrap the business action wherever the app does something valuable (an AI generation, an export, a send…). Pass the end user's id so it can bill them in live mode: const result = await crediball.action("generate_image", userId, () => generateImage(prompt)); action() registers the action, deducts credits BEFORE the work runs, then returns your callback's result. No result to wrap? Record it directly (same billing): await crediball.track("generate_image", userId); Use a stable action key. Nothing else to configure — the action shows up on the "Discovered actions" screen with a usage count and a suggested cost. Wrapping the ACTION (not the AI call) lets you swap models or rewrite internals later without touching billing. ## THEN RUN THIS — register what you wrapped (do not skip) Auto-discovery only sees code that RAN. Finish the wrapping above and the dashboard is still empty, so the developer has nothing to price until they manually exercise every path. Close that gap in one command, from the project root: npx @crediball/sdk sync It reads the action keys out of the source and registers them, so every action you just wrapped is on the Actions page immediately, ready to be priced and activated. It does NOT bill, run, or count anything as usage, and re-running it never overwrites a price, an activation, or a label edited in the dashboard — so run it again after adding actions. Then tell the developer: "your actions are on the dashboard's Actions page — set a credit cost, activate the ones you want to bill, then flip monetization to live." Keys built at runtime (e.g. `reader_turn_${mode}`) can't be read from the source; sync lists them as dynamic and they're discovered on their first call, as before. If the keys are computed but enumerable (say, one per plugin in a registry), declare them explicitly: await crediball.declare(["reader_turn_summary", "reader_turn_translate"]); ## Configure via MCP instead of the dashboard (optional, for the rest of setup) Everything past this point that's normally "go configure it in the dashboard" — pricing actions, top-up packages, the signup bonus, and flipping the app live — can instead be done by you (the coding agent) over MCP, in the same conversation, at https://www.crediball.ai/api/mcp (Streamable HTTP). Auth: a bearer token, either a personal access token (cb_pat_..., minted on the dashboard's /settings/tokens page, scoped to read / write:pricing / write:promos / write:credits / admin) or the app's own secret key (cb_live_...) as a single-app fallback scoped to read + write:pricing only. Tools that matter here: list_actions / activate_action / update_action_price (price + activate discovered actions), list_packages / create_package / update_package / delete_package (top-up packages), set_signup_bonus, and set_app_mode (flip sandbox <-> live — going live needs confirm: true AND a connected Stripe payout account, Billing -> Payouts in the dashboard; it errors with exactly what's missing rather than going live silently). If the developer wants this, connect to that endpoint with the token above and drive the rest of setup through those tools instead of telling them to click through the dashboard. ## Cover EVERY chokepoint (most missed step) Crediball only discovers actions you actually run — it can't see code you didn't instrument. Walk the codebase and add an action()/track() at EVERY place value is produced: - every route handler / server action / RPC that does real work - GENERIC DISPATCHERS especially: one handler that serves many modes (e.g. a 'use server' action behind several assistant features) is the #1 miss. Wrap it with a DYNAMIC, per-mode key so each mode becomes its own action: await crediball.action(`reader_turn_${mode}`, userId, () => runMode(mode)); - background jobs / cron / webhooks that generate output Key actions by FEATURE/use-case, never by model name. The same model can power many actions; many models can power one action. See "Don't let model calls leak into Actions" below for the exact pitfall this causes with the @crediball/sdk/ai wrappers. ## Optional: enrich actions with AI telemetry — @crediball/sdk/ai A telemetry layer on top of your actions. Wrap your model/client(s) ONCE and every AI call underneath contributes provider/model/token metadata — text, image, audio, video. Zero dependencies; works whether the app uses the Vercel AI SDK or raw provider SDKs, or a mix. Inside crediball.action(...), the wrappers ENRICH the action and never bill again. Used standalone (no action around them), each wrapped call bills its own event — handy when the model call itself is the priced unit, or to cover a dispatcher without editing every call site. import { wrapOpenAI, wrapAnthropic, wrapGemini, crediballMiddleware, instrument, withUser } from "@crediball/sdk/ai"; - Raw clients (text + image + audio + video): wrap once. const openai = wrapOpenAI(new OpenAI(), { event: "assistant" }); const anthropic = wrapAnthropic(new Anthropic(), { event: "chat" }); const genai = wrapGemini(new GoogleGenerativeAI(key), { event: "assistant" }); - Vercel AI SDK (text, every provider via one hook): const model = wrapLanguageModel({ model: openai("gpt-4o"), middleware: crediballMiddleware({ event: "chat" }) }); // per-call override: providerOptions: { crediball: { event: `reader_turn_${mode}`, userId } } - Anything else (custom provider, fetch-based, AI SDK image/speech): the catch-all HOF const cover = instrument((p) => openai.images.generate({ model: "gpt-image-1", prompt: p }), { event: "cover_art", modality: "image" }); - Identity: thread the end user through a request once; raw-client calls then bill them with no per-call changes: await withUser(session.userId, () => handleRequest(req)); Tracking fires BEFORE the call, so a live-mode out-of-credits user is stopped (402) before the expensive request runs. embeddings/moderations are off by default (pass includeOptIn:true). Token usage is captured automatically after non-streaming text calls (a separate non-billing report) and shown per action/model in the dashboard — no extra code, tokens never bill. - HIDDEN model client (Genkit, some LangChain setups): if the framework owns the model internally, there's no client instance to wrap, so the adapters can't populate the Models page. Pass the model info yourself on track()/action() and it shows up the same way: await crediball.track("assistant", userId, { metadata: { model: "gemini-2.0-flash", provider: "google", modality: "text" } }) Billing works fine without this — but the Models page stays empty until something tells Crediball which model ran, whether that's a wrapper or this manual metadata. ## Don't let model calls leak into Actions (the #1 integration bug) The wrappers above only ENRICH an action (no separate event, no double bill) when the wrapped model call runs INSIDE the exact crediball.action(...) callback. A very common mistake: bill up front with a bare crediball.track("my_event", userId), then call the (wrapped) model AFTER/OUTSIDE that scope, assuming it'll attribute there because it ran right after. It won't — the wrapped call has no context to inherit, so reportEvent falls back to billing its OWN event under the raw model id (gpt-4o, googleai/gemini-2.5-flash, gemini-2.5-flash-preview-tts, ...). That shows up as its own "discovered action" on the Actions page and can double-bill the user. The dashboard recognizes model-shaped keys and pulls them into a separate "looks like a model call" band with this fix attached — if the developer shows you one from there, the problem is in the code below, not in the pricing. // WRONG — leaks a phantom "gpt-4o" action, separate from "narrate_audio": await crediball.track("narrate_audio", userId); const wav = await openai.audio.speech.create({ model: "tts-1", input: text }); // RIGHT — wrap the call so it runs inside the SAME scope that was billed: const wav = await crediball.action("narrate_audio", userId, () => openai.audio.speech.create({ model: "tts-1", input: text })); Already billed and can't restructure to a single action() call (a streaming route, or the model call happens inside a callback/framework you don't own — e.g. Genkit)? Use runWith from @crediball/sdk/ai to attribute the later call to the event you already billed, without billing it again: import { runWith } from "@crediball/sdk/ai"; await crediball.track("narrate_audio", userId); // billed once, here await runWith({ event: "narrate_audio", userId, settled: true }, () => openai.audio.speech.create({ model: "tts-1", input: text })); // enriches, doesn't rebill This applies to EVERY chokepoint, not just the top-level dispatcher: a shared prompt/model called from several different features, run outside any crediball.action()/runWith() scope, pools ALL of their usage under one phantom model-id action instead of attributing it to each real one. Key actions by feature/use-case, never by model name — the model id belongs in metadata, not in the event key. ## Usage-based (dynamic) pricing — automatic, no code An action's price is composable: a flat BASE (always charged) + an optional VARIABLE part that scales with the call's AI cost. Set both in the dashboard (Actions → Edit → Pricing: "Base price" + "Add usage-based price" with a multiplier and optional min/max). Total = base + variable; base 0 = fully dynamic, variable off = fully fixed. When you use the AI adapters, the variable part is settled AUTOMATICALLY: post-call the adapter estimates the AI cost from token usage (built-in model price table) and charges multiplier × cost on top of the base. Nothing to add in code. Notes: - The base still gates pre-call (402 for out-of-credits); the variable settles post-call and may briefly take a balance slightly negative — the next call's base gate then blocks. - This works the same INSIDE crediball.action(...): the action charges the flat base once, up front; wrappers inside it report the AI cost so the variable part settles once against the action's own event. Base and variable are distinct components — no double charge. (What the action scope suppresses is a redundant second BASE charge from the nested wrapper.) - Currency: the estimated cost is USD (provider list prices) and is treated 1:1 with the credit unit — no FX conversion. The cost unit is nominal (a $1 and a €1 cost bill the same); tune the multiplier to your target margin. Model prices are approximate/dated — override when it matters. - Override prices per wrapper: wrapOpenAI(client, { pricing: { "gpt-4o": { input: 2.5, output: 10 } } }). - Image/audio/video have no tokens: pass an explicit cost via instrument(fn, { cost: (r) => 0.04 }). ## Handle "out of credits" (live mode) In live mode, tracking an activated event throws InsufficientCreditsError when the user can't afford it. ANY other failure (unreachable API, missing key, server error) throws CrediballApiError — handle it SEPARATELY from InsufficientCreditsError, and fail open: log it and let the request proceed. Crediball meters your app's value; it must never become the reason the app's core feature is down. A misconfigured key or a Crediball outage should cost a few unbilled calls, not an outage of your product. import { InsufficientCreditsError, CrediballApiError } from "@crediball/sdk"; try { await crediball.track("generate_image", userId); } catch (e) { if (e instanceof InsufficientCreditsError) { /* show your paywall / top-up UI */ } else if (e instanceof CrediballApiError) { /* log it; do NOT block the request */ } else throw e; } ## Signup bonus, balance, and top-ups (purchase helpers on the shared client) const client = crediball.getClient(); const { balance } = await client.getBalance(userId); await client.topup({ userId, amount: 20 }); // grant credits const { currency, packages, subscriptionPlans } = await client.listPackages(); // Configure the new-user signup bonus + packages + subscription plans in the dashboard // (Packages page). The signup bonus is granted automatically on a user's first activity. ## Two kinds of API key - Secret key (cb_live_...): server-side only, full access (track, topup, subscribe). NEVER send it to a browser. - Publishable key (cb_pub_...): safe to ship to a browser. Read-only: balance, usage, event costs, package list. Powers everything in "Credit UI" below. Both keys are on the app's Integrate page in the dashboard. ## Credit UI (@crediball/react) — three tiers, one live data source All three read from one wired with the PUBLISHABLE key: import { CrediballProvider } from "@crediball/react"; CRITICAL: publishableKey AND userId must both be non-empty AT RENDER TIME, or the provider makes ZERO calls to Crediball — the paywall then shows a generic empty fallback (no packages, no balance) and its buttons do nothing. Two things break this in practice: - the publishable-key env var missing its public prefix (see Setup) → undefined in browser; - userId still resolving from async auth (Firebase/Clerk/etc.) → mount the provider only once the user id exists. Sanity check: with the app open, the browser Network tab must show GET .../public/balance? userId=... firing. If it doesn't, one of the two props is undefined — fix that first. PAYMENTS ARE AUTOMATIC: the paywall renders the packages, custom-amount field, AND subscription plans you configured in the dashboard, and clicking any of them starts a Stripe Checkout itself (via the publishable key) and redirects — NO onTopup/onSelectPackage/onSelectPlan handler required. Subscriptions bill recurringly and Crediball grants the plan's credits each period automatically. Just connect payouts in the dashboard (Billing → Payouts). Pass onTopup/onSelectPackage/onSelectPlan ONLY to override with your own checkout flow. The custom-amount field auto-appears from your dashboard's custom-topup setting; you don't need to pass allowCustom. CANCELLATION IS NOT AUTOMATIC — unlike checkout, it needs your secret key, so it can only run server-side. If you don't wire onCancelSubscription, does not fall back to anything: no Cancel button is rendered on the active-subscription banner at all. Wire it with a server action that calls cancelSubscription() using the SAME secret key you already pass to crediball.init() — do NOT create a second key for this: // app/actions/crediball-actions.ts ('use server') "use server"; import crediball from "@crediball/sdk"; // already init'd with CREDIBALL_API_KEY elsewhere export async function cancelSubscription(userId: string, subscriptionId: string) { await crediball.getClient().cancelSubscription({ userId, subscriptionId }); } // Providers.tsx cancelSubscription(userId, subscriptionId)} > Editing a plan's price affects only NEW subscriptions; existing subs keep their original price. 1) Drop-in components — credits/balanceLabel are read from context automatically once wrapped in the provider above: import { CreditsBadge, CreditIndicator, PaywallModal, ActionCostList } from "@crediball/react"; // settings sidebar // top bar // or let the provider auto-open it on 402 // "what costs what" price list lists every action the app currently BILLS, with the label and credit cost set on the dashboard's Actions page, and a "+ usage" marker on actions that also charge a share of the AI cost. NEVER hardcode action names or prices beside it — rows come from useCrediball().costList at runtime, so re-pricing on the dashboard needs no code change. Actions still in "detected" aren't billed and don't appear; with none active the list renders nothing, by design. 2) Variables — small components you place anywhere; they inherit the surrounding font/color so they read naturally inline: import { CrediballBalance, CrediballUsage, CrediballTopUpButton, CrediballLowCreditWarning } from "@crediball/react";

You have credits left.

used this period.

Add credits 3) Headless — full control, no markup of ours. Use this if the app already has its own credit-management UI and just needs Crediball's live data/actions to drive it: import { useCrediball } from "@crediball/react"; const { balance, usage, isLow, costOf, costList, topUp, openPaywall } = useCrediball(); costOf(action) prices one action; costList is the whole live catalog of billed actions ({ key, label, costCredits, variableEnabled }, most-used first) for your own pricing screen. Not using React at all? Skip @crediball/react and drive the same data straight from the REST API below (GET /public/balance, /public/usage, /public/costs, POST /credits/topup) — fully headless, no Crediball package required on the client. 4) Naked components — the middle ground between 1) and 3), and the right default when the host app has its own design system. The dashboard's UI-components page has a "Use my app's design system instead" link: it hands out the component's ENTIRE source, built on the hooks above, with every className left empty and no styles of its own. The developer owns that file; you style it with THEIR components and tokens. If a user pastes one of these in, the correct integration is: - style it with the host app's own design system — do NOT set --crediball-* variables, and do NOT replace it with the drop-in from @crediball/react (that defeats the point); - keep importing useCrediball / useReferral from @crediball/react — they carry the live data and the billing/checkout wiring; - keep the early-return guards and null-balance placeholders as written; - keep it inside . A naked paywall additionally needs so the provider's own built-in modal doesn't open on top of it, and drives its offers from useCrediball(): read packages / subscriptionPlans / customTopup, sell them with selectPackage(pkg, code?) / selectPlan(plan) / topUp(amount, code?), and surface checkoutError. Never hardcode amounts or prices — they all come from the dashboard. Reliable paywall pattern regardless of tier — usePaywall (or the provider's automatic 402/SSE detection): - trigger() / showPaywall() ALWAYS opens the modal, even after "Not now" was clicked - there is no "already shown" state to manage — call it unconditionally on every InsufficientCreditsError. For SSE/streaming routes the 402 arrives as a stream event (HTTP 200, not a status code) — check for code "insufficient_credits" in the chunk. Not using React? @crediball/elements ships the same three tiers as framework-agnostic web components — any framework, or none at all (a plain Add credits is the web-component sibling of : it lazily fetches the invite link + stats, auto-captures a ?ref= visit, and renders nothing when referrals are off (set capture-referrals="false" to opt out of the auto-capture). also pays out of the box: picking a package, a custom amount, or a subscription plan starts the Stripe Checkout itself and redirects (connect payouts in the dashboard). Each pick first fires a cancelable event (crediball-select-package / crediball-topup / crediball-select-plan) — call event.preventDefault() in a listener to run your own flow instead. Theming — one set of CSS variables, every component (React and web components) reads the same --crediball-* custom properties, falling back to Crediball's defaults. Set them once anywhere in the ancestor tree (e.g. :root) — no per-component props needed: :root { --crediball-accent: #7c3aed; /* buttons, highlights */ --crediball-on-accent: #ffffff; /* text on accent */ --crediball-ink: #111827; /* primary text */ --crediball-canvas: #ffffff; /* card/modal background */ --crediball-hairline: #e5e7eb; /* borders */ --crediball-radius: 9999px; /* pill/button corners */ --crediball-radius-card: 16px; /* card/modal corners */ } All of the above are self-contained (no CSS framework required) and never show Crediball branding — it stays invisible to your end users regardless of which tier you use. Or theme from the dashboard: styles (and default copy) the developer saves on the dashboard's UI-components page publish automatically to the connected app — the SDK serves them alongside packages and applies them as the same --crediball-* variables, no code change needed. A published dashboard theme takes precedence over --crediball-* vars you set in CSS; pass applyRemoteTheme={false} to (or apply-remote-theme="false" on a web component) to opt out and theme purely from CSS. ## Referrals (invite friends, earn credits) — enable on the dashboard's Referrals page Crediball owns the whole referral pipeline (lazy code generation, click tracking, conversion validation, reward payout); the app only renders UI. Rewards are granted server-side ONLY, when the dashboard-configured conversion condition (signup, first paid top-up, first/specific action, credits spent) is observed on a secret-key call — a browser can never trigger a payout. React (zero wiring): auto-captures ?ref=CODE visits into localStorage and auto-attaches the code when userId appears. Render the invite UI with: import { ReferralCard, useReferral } from "@crediball/react"; // drop-in card const { link, code, copy, share, clicks, rewards, loading } = useReferral(); // headless Both render nothing / report enabled=false while the program is off in the dashboard. The paywall also promotes referrals automatically: with referrals enabled, and show a "Refer a friend" row with the user's copyable invite link. Server (optional, for explicit control): const referral = await crediball.getReferral(userId); // { code, link, clicks, rewards } await crediball.completeReferral(userId, codeFromBrowser); // attach after your own signup flow Links: paste your app URL (or leave it empty) on the dashboard and Crediball appends ?ref=CODE, which the provider captures automatically — no route needed. A template with {code} (e.g. https://yourapp.com/r/{code}) makes a path-style link, but only works if your app serves that route and calls captureReferral there (otherwise the link 404s). ## userId Use your own app's user id (your auth's id). Users are created automatically on first track/topup. End users never authenticate with Crediball. ## REST API (if not using the SDK), base: https://www.crediball.ai/api Secret-key routes — header: Authorization: Bearer cb_live_... (apps are created in the dashboard) POST /events/track { event, userId? } // 402 if insufficient in live mode POST /credits/topup { userId, amount, action? } GET /user/:id/balance GET /actions // events: [{ key, label, status, costCredits, usageSandbox, usageLive }] GET /packages // { currency, packages, subscriptionPlans, customTopup, signupBonus } GET /ledger/user/:userId GET /referral?userId= // { enabled, code, link, clicks, rewards } — code created lazily POST /referral/complete { userId, code } // attach a captured code; converts on 'signup' condition Public, browser-safe, CORS-open routes — header: Authorization: Bearer cb_pub_... (or a "key" query param, for EventSource) — read-only, power the Credit UI above: GET /public/balance?userId= // { userId, balance } GET /public/usage?userId= // { userId, usedCredits, periodStart, periodEnd, source } GET /public/costs // { events: [{ key, label, costCredits }] } — active events only GET /public/packages?userId= // same shape as /packages GET /public/stream?key=&userId= // Server-Sent Events: pushes { balance, usage } on change GET /public/referral?userId= // { enabled, code, link, clicks, rewards } POST /public/referral/click { code } // count an invite-link click POST /public/referral/complete { code, userId } // attach as PENDING only (rewards stay server-side)