Skip to content

Charging Your Users

This page is about taking money from your app’s end users, into your own Stripe account.

For what Butterbase charges you — plan allowances, AI credits, spending caps, and usage — see Plans & Usage.

Butterbase ships a complete Stripe Connect integration so your app can charge its own users — subscriptions, one-time products, or both. You do not write Stripe code, host a checkout page, or run a webhook server.

  • Money goes straight to your Stripe account. Butterbase creates a Stripe Express account owned by you; charges are made on that account, and Stripe pays out to your bank.
  • 0% platform fee. Butterbase takes no cut of Connect transactions — platform_fee_cents is always 0. You pay Stripe’s normal processing fees and nothing else.
  • Hosted checkout. Every purchase returns a Stripe Checkout URL. PCI scope stays with Stripe.
  • Webhooks already handled. Subscription activation, renewal, failure, cancellation, and refunds are processed for you and written to your app’s tables.
  • A subscription/order model in your database. app_plans, app_products, app_subscriptions, and app_orders are queryable from your functions and RLS policies.
  • Works everywhere. Dashboard UI, REST API, CLI (butterbase app-billing), TypeScript SDK (bb.billing.*), and MCP.
  • Your app is created and its users sign in through Butterbase Auth — checkout needs a real row in app_users with an email. See Authentication.
  • You have (or can create) a Stripe account, and you can complete Stripe’s identity/bank verification for your country.
  • You know which model you want: recurring plans, one-time products, or both.
  • You have a successUrl and cancelUrl in your frontend for Stripe to return users to.

Step 1 — Onboard your Stripe Connect account

Section titled “Step 1 — Onboard your Stripe Connect account”

This creates a Stripe Express account for the app and hands you a Stripe-hosted onboarding link.

Dashboard (recommended): go to App → Monetization → Stripe Connect → Connect with Stripe. You’ll be redirected to Stripe, then back to the dashboard.

REST API:

POST /v1/{app_id}/billing/connect/onboard
Authorization: Bearer {platform_api_key}
{
"accountId": "acct_1Nv...",
"onboardingUrl": "https://connect.stripe.com/setup/e/acct_1Nv.../..."
}

SDK:

const { data } = await bb.billing.connectOnboard();
window.location.href = data.onboardingUrl;

Open onboardingUrl and complete Stripe’s flow — business details, identity verification, and a payout bank account. The link is single-use and expires; call the endpoint again to get a fresh one. It is normal for this to take a few minutes, and for some countries Stripe may hold payouts_enabled until it finishes reviewing your documents.

Step 2 — Confirm you can accept payments

Section titled “Step 2 — Confirm you can accept payments”
GET /v1/{app_id}/billing/connect/status
{
"accountId": "acct_1Nv...",
"chargesEnabled": true,
"detailsSubmitted": true,
"payoutsEnabled": true
}
FieldMust be true before…
detailsSubmittedStripe considers onboarding finished
chargesEnabledany checkout will work — Butterbase refuses to create a session without it (CONNECT_NOT_READY)
payoutsEnabledStripe will actually move money to your bank

If chargesEnabled is false, re-run Step 1 and finish whatever Stripe is still asking for.

You want to sellUseObjects created
Monthly or yearly accessPlansapp_plansapp_subscriptions
A one-off purchase (digital goods, credits, lifetime access)Productsapp_productsapp_orders
BothBoth

Plans and products are independent; an app can have any number of each.

POST /v1/{app_id}/billing/plans
Authorization: Bearer {platform_api_key}
{
"name": "Pro",
"priceCents": 999,
"interval": "month",
"features": ["Unlimited projects", "Priority support", "Custom domain"]
}
FieldTypeNotes
namestring, 1–100 charsShown on the Stripe Checkout line item
priceCentsinteger ≥ 0Cents. 999 = $9.99. Subscriptions are charged in USD.
interval"month" | "year"Defaults to "month"
featuresstring[]Free-form marketing bullets — your UI decides what to do with them

The response is the created row, including the id (a UUID) you’ll pass to subscribe.

Terminal window
butterbase app-billing plans create --name "Pro" --price-cents 999 --interval month
butterbase app-billing plans list
const { data: plan } = await bb.billing.createPlan({
name: 'Pro', priceCents: 999, interval: 'month',
features: ['Unlimited projects', 'Priority support'],
});

Changing a plan:

PUT /v1/{app_id}/billing/plans/{plan_id}
{ "priceCents": 1299, "active": true }

You can update name, priceCents, features, and active. You cannot change interval — create a new plan instead. Setting active: false retires a plan: it stops accepting new subscribers, and existing subscriptions keep running.

POST /v1/{app_id}/billing/products
Authorization: Bearer {platform_api_key}
{
"name": "Premium Template Pack",
"description": "50+ premium UI templates",
"priceCents": 2999,
"metadata": { "category": "templates", "sku": "TPL-50" }
}
FieldTypeNotes
namestring, 1–100 charsCheckout line item
descriptionstring, ≤ 500 charsOptional; shown at checkout
priceCentsinteger ≥ 0Cents
metadataRecord<string,string>Your own keys — comes back on the order, use it to drive fulfillment

Currency defaults to usd. Update with PUT /v1/{app_id}/billing/products/{product_id} (name, description, priceCents, active, metadata).

Terminal window
butterbase app-billing products create --name "Template Pack" --price-cents 2999

Listing plans and products is public — no auth needed, so you can render a pricing page to logged-out visitors.

GET /v1/{app_id}/billing/plans → { "plans": [...] }
GET /v1/{app_id}/billing/products → { "products": [...] }
const { data: plans } = await bb.billing.listPlans();
const { data: products } = await bb.billing.listProducts();

Filter out rows where active is false before rendering — inactive rows are still returned so historical subscriptions can resolve their plan name.

Subscribe:

POST /v1/{app_id}/billing/subscribe
Authorization: Bearer {end_user_app_jwt}
{
"planId": "3f9a…-uuid",
"successUrl": "https://yourapp.com/billing/success",
"cancelUrl": "https://yourapp.com/pricing"
}
{ "sessionId": "cs_live_…", "url": "https://checkout.stripe.com/c/pay/cs_live_…" }

Purchase:

POST /v1/{app_id}/billing/purchase
Authorization: Bearer {end_user_app_jwt}
{ "productId": "8b21…-uuid", "successUrl": "", "cancelUrl": "" }
{ "sessionId": "cs_live_…", "url": "https://checkout.stripe.com/…", "orderId": "0c7d…-uuid" }

A pending row is written to app_orders before checkout opens, so you always have an id to correlate against.

Then redirect:

const { data, error } = await bb.billing.subscribe({
planId: plan.id,
successUrl: `${window.location.origin}/billing/success`,
cancelUrl: `${window.location.origin}/pricing`,
});
if (error) return showError(error);
window.location.href = data.url;

successUrl / cancelUrl are optional but you should always set them — the defaults point at the Butterbase dashboard, not your app.

Step 7 — Understand what happens after payment

Section titled “Step 7 — Understand what happens after payment”

Payment is not final when the user returns to successUrl. It’s final when Stripe confirms it. Butterbase runs the Connect webhook handler and writes the result into your app’s tables:

Stripe eventWhat Butterbase does
checkout.session.completedActivates the subscription, or marks the order paid
invoice.paidRolls the subscription period forward
invoice.payment_failedMarks the subscription past_due
customer.subscription.updatedSyncs status and cancel_at_period_end
customer.subscription.deletedMarks the subscription canceled
payment_intent.payment_failedMarks the order failed
charge.refundedMarks the order refunded, stamps refunded_at

Events are deduplicated by Stripe event id, so retries are safe.

On Butterbase Cloud there is nothing to configure. Your Express account’s events flow to the platform endpoint automatically. Write your app to read state from GET /billing/subscription and GET /billing/orders — do not try to grant access from the successUrl redirect alone, because the webhook may land a moment later.

Step 8 — Gate access on subscription status

Section titled “Step 8 — Gate access on subscription status”
GET /v1/{app_id}/billing/subscription
Authorization: Bearer {end_user_app_jwt}
{
"subscription": {
"id": "", "plan_id": "", "status": "active",
"current_period_start": "2026-08-01T00:00:00Z",
"current_period_end": "2026-09-01T00:00:00Z",
"cancel_at_period_end": false,
"plan_name": "Pro", "price_cents": 999, "interval": "month",
"features": ["Unlimited projects"]
}
}

Returns { "subscription": null } when the user has none. Only active, trialing, and past_due subscriptions are returned — a canceled or expired one reads as null, which is exactly the check you want:

const { data: sub } = await bb.billing.getSubscription();
const isPro = sub !== null && sub.status === 'active';

Enforce it on the server, not just in the UI. The reliable pattern is a serverless function that queries app_subscriptions for the caller before doing paid work:

SELECT 1 FROM app_subscriptions
WHERE app_id = $1 AND user_id = $2
AND status IN ('active', 'trialing')
AND current_period_end > now();

You can also drive row-level security policies from the same table so premium rows are unreadable without a live subscription.

POST /v1/{app_id}/billing/cancel
Authorization: Bearer {end_user_app_jwt}

Sets cancel_at_period_end = true. The user keeps access until current_period_end, then Stripe emits customer.subscription.deleted and the subscription stops being returned. Returns 404 if there’s no active subscription.

There is no “resume” endpoint — a user who changes their mind subscribes again.

GET /v1/{app_id}/billing/orders → { "orders": [...] }
GET /v1/{app_id}/billing/orders/{order_id} → one order

Both are end-user scoped: a user only ever sees their own orders.

StatusMeaning
pendingCheckout session created, payment not completed
paidPayment succeeded — fulfill here
failedPayment failed
refundedRefunded; refunded_at is set

Never grant access on pending. Poll for paid from your success page, or read the order inside a function before serving the purchased asset. The product’s metadata comes back on the order — use it to decide what to deliver.

  1. Put your Stripe account in test mode and complete Connect onboarding with Stripe’s test business details.
  2. Create a cheap plan and product.
  3. Sign in to your app as a real end user and hit subscribe / purchase.
  4. Pay with 4242 4242 4242 4242, any future expiry, any CVC. Use 4000 0000 0000 0341 to test a failed payment.
  5. Confirm the webhook fired: GET /billing/subscription flips to active, or the order flips to paid.
  6. Test the cancel path and the refund path (refund the charge in Stripe, confirm the order flips to refunded).
  • connect/status shows chargesEnabled and payoutsEnabled
  • Every plan/product you don’t want sold is active: false
  • successUrl / cancelUrl point at your domain, not the dashboard
  • Access is enforced server-side, not only in the frontend
  • You have terms of service and a refund policy — Stripe requires them for Connect accounts
Error codeHTTPCauseFix
CONNECT_NOT_CONFIGURED400No Stripe account attached to this appRun Step 1
CONNECT_NOT_READY400charges_enabled is false on the Stripe accountFinish Stripe onboarding; re-check Step 2
PLAN_NOT_FOUND404Bad planId, wrong app, or the plan is active: falseRe-list plans and check the UUID
PRODUCT_NOT_FOUND404Same, for products
USER_NOT_FOUND404No app_users row for the token’s user idThe caller must be a real Butterbase-Auth user of this app
403 Not authorized403Non-owner calling a developer endpointUse the app owner’s credentials
401 on subscribe/purchase/orders401Sent a token that isn’t an end-user app JWT or a platform tokenSee Step 6
Nothing activates after paymentYou granted access from the redirect instead of the recorded state, or (self-hosted) the webhook isn’t wiredRe-read GET /billing/subscription or the order; self-hosted, check Step 7 and Stripe’s delivery log

Developer endpoints — platform auth, owner only:

MethodPathPurpose
POST/v1/{app_id}/billing/connect/onboardCreate/refresh the Stripe Connect account link
GET/v1/{app_id}/billing/connect/statusOnboarding + charges + payouts status
POST/v1/{app_id}/billing/plansCreate a subscription plan
PUT/v1/{app_id}/billing/plans/{plan_id}Update a plan
POST/v1/{app_id}/billing/productsCreate a product
PUT/v1/{app_id}/billing/products/{product_id}Update a product

Public:

MethodPathPurpose
GET/v1/{app_id}/billing/plansPlan catalog
GET/v1/{app_id}/billing/productsProduct catalog

End-user endpoints — app JWT:

MethodPathPurpose
POST/v1/{app_id}/billing/subscribeStart a subscription checkout
GET/v1/{app_id}/billing/subscriptionCurrent subscription
POST/v1/{app_id}/billing/cancelCancel at period end
POST/v1/{app_id}/billing/purchaseBuy a product
GET/v1/{app_id}/billing/ordersOrder history
GET/v1/{app_id}/billing/orders/{order_id}Single order
TableHolds
app_plansYour subscription plans (name, price_cents, interval, features, active)
app_productsYour one-time products (name, description, price_cents, currency, metadata, active)
app_subscriptionsOne row per user per subscription (status, period bounds, cancel_at_period_end)
app_ordersOne row per purchase attempt (amount_cents, platform_fee_cents, status, refunded_at)

These live in your app’s runtime database, so functions and RLS policies can read them directly.

You can skip all of this and call Stripe (or any other processor) yourself from a serverless function, storing purchase state in your own tables. You lose the hosted webhook handling and the built-in subscription model, and you take on PCI and key management. The Monetization guide has a compact end-to-end walkthrough of the Connect path.