Skip to content
Guides
Build guide

Paid plans that actually gate features: plans, trials, limits and seats for a vibe-coded app

Every builder takes the money. The hard half is after: the plan has to change what the app does, and nobody may mark themselves paid. Each step with a prompt, from the webhook to the server gate.

Dharmendra Jagodana13 min read

In short

A paid plan gates a feature when three things agree: the plan lives on the customer workspace, not in your database; the button checks it in the browser; and the server checks it again before doing the work. Stripe takes the card, BuildBase keeps the plan, and a signed webhook ties the two together.

"The worst Lovable payment bug is the one where the payment works." A developer who fixes vibe-coded apps for a living wrote that on 20 October 2026, and it is the whole subject of this guide. The customer is charged. The app never notices. The paid feature stays grey, the customer emails you, and you refund a payment that worked perfectly.

Every builder now takes the money. Lovable has Paddle and Stripe built in, Bolt and Replit have Stripe connectors, v0 has Stripe in the Vercel Marketplace, Base44 has its own payments by Wix. What none of their docs cover, as of 27 October 2026, is the part after the checkout: a plan that belongs to a team rather than a person, a trial that ends on its own, a seat count, a usage limit, and a feature that appears when the plan says so and disappears when the card fails. That is what you are building here.

When it works: a customer picks a plan on a test card, the paid feature appears without a reload, a colleague they invited gets it too because the plan belongs to the workspace, the trial ends and the app knows, and when the card fails on renewal the feature closes and an email goes out. And at no point does your app hold a column that says paid: true.

What we ran on 27 October 2026: the SDK's plan, entitlement and webhook pieces are the ones the Next.js guide and the existing billing guides are built on, and the webhook route is the one npx buildbase init writes. The prompts below are what we hand to an agent; the "what you should see" lines are what those pieces do. We have not run this flow inside a builder's own chat today, so where a builder's behaviour matters it is named.

Before you start

An app with BuildBase sign-in already working (the v0 guide or the Lovable guide gets you there), a Stripe account in test mode, and the signing secret of a webhook endpoint created in the console. A workspace per customer is already on by default; this guide assumes it.

1. Decide what "paid" changes, in writing

Before any prompt, write down what each plan unlocks, as a short list of feature names: export, api-access, five-seats, priority-support. Those names become feature flags tied to plans, and they are the only vocabulary the rest of this guide uses. A plan that does not change what the app does is a donation, not a subscription.

Why first? Because an agent asked for "add Stripe subscriptions" builds a checkout and stops. It has no idea which button the money is supposed to turn on, so it turns on nothing, and that is the bug in the opening line. Naming the features before the prompt is the difference between "add billing" and "gate the export feature behind the Pro plan".

Keep two kinds of thing apart while you write the list, because they are wired differently:

  • A feature the plan includes or does not. Export on Pro, not on Launch. This is a flag tied to the plan.
  • A quantity the plan allows. Five seats, 1,000 generations a month, 10 GB. This is a limit or a credit balance, and it has its own guide: a credits wallet when it is a balance, enforcing quotas when it is a cap.

The distinction between a flag and an entitlement is worth ten minutes before you prompt: entitlements are not feature flags explains why "sees the button" and "may call the endpoint" are two different questions, and why you want the second.

What you should see: a list of three to six feature names and which plan carries each. Paste it into the prompts below where they say <features>.

2. Create the plans in the console, synced to your Stripe

Plans, prices and trials live in the BuildBase console and sync to your own Stripe account; Stripe is yours, at Stripe's rates, and the platform fee is 0%. No card data touches your app, ever. In the console, create the plans from step 1 with a monthly and a yearly price each, and mark which features each plan includes.

Three settings matter more than they look:

The trial. A free trial that becomes a plan without a second checkout. Decide the length now, and decide whether it needs a card. Our own plans run a 7-day trial with no card; a trial without a card walks through what changes when you skip the card, chiefly that the trial ending has to do something on its own.

Versions. A plan change creates a new version rather than editing the old one, so raising a price never moves an existing customer unless you choose to move them. Plan versioning is the day-you-raise-prices guide, and proration on plan change covers what the customer is charged when they switch mid-month.

Currencies. Pick them before the first subscription. Choosing your currencies before you have customers is short and saves a migration.

The pricing page and the checkout come ready-made, in 21 currencies, and you can use the SDK's or draw your own against the same plans. If you draw your own, the annual and monthly toggle is the one piece worth copying rather than inventing, because the yearly price has to come from the plan rather than from a multiplication in the component, or the two drift the first time you change one.

What you should see: in Stripe's test dashboard, products and prices that match the console, created by the sync rather than by hand. If you made a price in Stripe first, that is the wrong direction; the console is the source and Stripe the mirror, and keeping a pricing page honest against your plans explains why.

3. The webhook: where the plan reaches your app

The plan never lives in your database. It lives on the customer's workspace in BuildBase, and your app learns about changes through signed webhook events. The route that receives them is the one npx buildbase init writes, or the one this prompt asks for:

Add a POST route at /api/webhooks/buildbase (app/api/webhooks/buildbase/route.ts on Next.js). Verify every delivery with parseWebhookEvent from @buildbase/sdk on the raw request body, using BUILDBASE_WEBHOOK_SECRET from the environment, and reject anything that fails verification with 401.
Handle these events by name and nothing else: subscription.created, subscription.upgraded, subscription.downgraded, subscription.canceled, subscription.suspended, subscription.trial_will_end, subscription.trial_expired, payment.failed, payment.succeeded.
Do not write the plan, the trial state or any "paid" flag to my database. The workspace carries them. Use the events to send email, post to Slack, or log, and respond 200 quickly.

Then, in the console, point an endpoint at that address and copy its signing secret into BUILDBASE_WEBHOOK_SECRET.

Why "do not write the plan to my database" is in the prompt, in bold in our heads: the audit that keeps coming up in these guides found, in February 2026, "development endpoints still in production that let anyone mark themselves as a paid user and give themselves unlimited credits". That endpoint existed because the plan was a row the app owned. When the plan is a property of the workspace in BuildBase, read through the SDK, there is no row to flip and no endpoint to leave behind.

What you should see: subscribe on a test card, and the console's delivery log for the endpoint shows subscription.created delivered with a 200 from your route. Stop your server, cancel the subscription, start it again: the subscription.canceled delivery is retried and lands, because deliveries are tried up to five times over about two and a half minutes. What a webhook is, and what happens when delivery fails has the ladder.

Correcting potential issues. If the agent wrote a Stripe webhook handling invoice.paid or customer.subscription.updated directly, it built the billing a second time, next to the one in the console. Paste: "Remove the direct Stripe webhook. BuildBase receives Stripe's events; my app receives BuildBase's events at /api/webhooks/buildbase only." If it added a plan column to a users or workspaces table, paste: "Remove the plan column. The plan is read from the workspace through the SDK, never stored in my database."

4. The button: gate the feature in the browser

In the app, the gate is a component around the feature, with the feature name from step 1 as its slug. The SDK ships two for this, and the difference matters.

import { WhenEntitled, WhenWorkspaceFeatureEnabled } from '@buildbase/sdk/react';

// The current workspace's plan includes this feature, and nobody has turned it off.
<WhenEntitled slug="export" fallbackComponent={<UpgradePrompt />}>
  <ExportButton />
</WhenEntitled>

// The flag is on for this workspace, whichever plan it is on.
<WhenWorkspaceFeatureEnabled slug="beta-dashboard">
  <BetaDashboard />
</WhenWorkspaceFeatureEnabled>

WhenEntitled answers the plan question: does this workspace's plan include the feature, and is it still available (a suspended subscription says no). WhenWorkspaceFeatureEnabled answers the switch question: is the flag on for this workspace, which is how you run a beta for the customers you choose, or let a workspace admin turn an allowed feature on or off from the built-in settings screen. Most paid features want the first; most betas want the second.

Prompt for it:

Wrap each paid feature in the app in <WhenEntitled slug="<feature>"> from @buildbase/sdk/react, using these feature slugs: <features>. Where a feature is locked, show a short upgrade prompt that opens the SDK's plan screen; do not hide the button silently.
Show the current plan, the trial end date and an upgrade or cancel action on the /settings page using the SDK's built-in billing screen. Do not build a custom pricing table; use the SDK's.

What you should see: on a free workspace the export button shows the upgrade prompt; subscribe on a test card and it becomes the real button without a reload, because the workspace's plan changed under the component. A teammate in the same workspace sees the same, because the plan belongs to the workspace rather than to the person who paid. That last part is what a per-user subscription cannot do, and it is why Lovable's one subscription per user, documented as the default on 27 October 2026, is the wrong shape for anything with teams.

Key takeaway

The browser gate decides what people see. It never decides what they may do. Anything a component hides can be requested by hand, so step 5 is the one that makes the plan real.

5. The server: the gate that cannot be bypassed

Every feature the browser gates has a server route that does the work, and that route checks the plan again before doing it. This is the step generated apps skip, and it is the step the audits catch, so it gets its own prompt rather than a sentence in another one.

In every server route that performs a paid feature (<list the routes>), check the current workspace's entitlement for the feature slug through the BuildBase server SDK before doing the work, using the session from the request, and return 402 with a short JSON error when the workspace is not entitled. Never trust a plan or feature value sent by the browser in the request body or headers. Do not cache the answer across requests.

Why 402 and why "never trust the request body": the other finding in that February audit was "a live marketplace with real Stripe payments where any logged-in user could grant themselves admin and hijack payment routing with a single request", and a Mastodon post from a developer who reviews AI-built code names the pattern he looks for first: "server routes that take a user id from the request body instead of the session". A plan in the request body is the same mistake with a different noun. The server asks BuildBase who this session is and what its workspace is entitled to, and ignores whatever the browser claims.

Server-side quota enforcement shows the same shape for a limit, and the reason the answer should not be cached for long: a permission change reaches a signed-in user within the hour, and why that is is a trade we made on purpose.

What you should see: in the browser's developer tools, copy the request the export button makes and replay it from a free workspace's session. It should come back 402, every time, whatever you put in the body.

Correcting potential issues. If the route checks a header like x-plan: pro or a field in the JSON body, paste: "Remove every plan or feature value read from the request. Derive the workspace from the session and ask the BuildBase server SDK for the entitlement." If the route checks nothing because "the button is already hidden", paste the same line, and then read step 4's takeaway again.

6. Seats, when the plan is per person

Some plans are priced per seat: three members included, more at a price. The workspace keeps the seat count, inviting past it is refused by the server with a named error, and the SDK's useSeatStatus counts pending invitations against the ceiling the same way the server does, so the invite button can say "2 of 5 seats" before anyone clicks it.

Charge per seat is the full guide, and the prompt is one line:

Price the Pro plan per seat with three seats included. Show seat usage from useSeatStatus on the members screen, and let an admin buy more seats through the SDK's plan screen. Do not count members in my own database; the workspace knows.

What you should see: the fourth invitation on a three-seat plan is refused with a seat-limit error the members screen shows in words, not a silent nothing. If you ever see "something went wrong" instead, the error's code was dropped on the way to the screen; the SDK's invitation errors carry it.

7. The trial ends, the card fails: what happens on its own

A subscription has a life after checkout, and the events in step 3 are how the app keeps up without you watching. Three of them deserve a workflow rather than a line in the webhook route, because a workflow is a thing you can see ran.

  • subscription.trial_will_end: a heads-up email a few days before, with a link to the plan screen. This is the single most effective email a SaaS sends, and generated apps never send it because nobody asked.
  • payment.failed: an email saying the card failed, with the link to update it, and a note to your Slack channel. The subscription is not cancelled on the first failure; the dunning sequence handles retries, and usage that does not match the invoice explains the states between "paid" and "gone".
  • subscription.suspended: the feature gate from step 4 closes on its own, because WhenEntitled reads the workspace's state, and a last email says so.

In the console's workflow editor these are three triggers, two email steps and one Slack step, published without a deploy. In the app, nothing changes, which is the point.

What you should see: let a test card fail on renewal (Stripe's test cards include one that fails on the second charge). The payment.failed delivery lands, the email goes out from your sending domain, Slack gets a line, and the export button on that workspace shows the upgrade prompt again once the subscription is suspended.

Where the builder's own payments are the better pick

Keep Lovable's built-in payments, or Bolt's or Replit's Stripe connector, when your app sells one subscription to one person and the plan does not need to change what the app does beyond a single "premium" check. Inside the builder they are a prompt away, and Lovable's Paddle option (5.0% plus 50 cents per transaction, read from its docs on 27 October 2026) handles the tax side, which Stripe alone does not. If you sell in many countries and want someone else to be the merchant of record, that is a real advantage we do not offer; our billing is Stripe, bring your own.

Choose this guide's shape when the plan belongs to a team, when a feature has to open and close with the subscription, when seats or limits are part of the price, or when a trial has to end without you. Lovable's docs describe one active subscription per user by default, one provider per project, and a project that cannot be forked once payments are on; none of Bolt's, Replit's or v0's describe a plan per team at all. That layer is what the webhook, the two gates and the workflows above add, with Stripe still yours. The Clerk and Stripe combination, and when to skip it is the same comparison from the other side.

What breaks, and how you can tell

Paid, still on the free plan. The endpoint in the console points at the wrong address, or BUILDBASE_WEBHOOK_SECRET is empty. The delivery log shows each attempt: a 401 means the secret, a timeout means the route.

The feature appears for the person who paid and not for their teammate. Something stored the plan per user. Remove it; the plan is on the workspace, and WhenEntitled reads the workspace.

The feature works after cancelling. The server route in step 5 is missing, or it cached the answer. Replay the request from the cancelled workspace's session and watch for the 402.

Two customers were charged twice for one upgrade. Your route did the work twice because the retry ladder delivered the same event twice and nothing noticed, which is what the event id is for: it is identical on every retry, so store it and make a second sighting a no-op. Idempotency in billing is the fix.

The pricing page shows a price Stripe does not charge. Someone edited Stripe by hand. The console is the source. Re-sync, then read keeping a pricing page honest before it happens again.

The trial ended and nothing happened. No workflow is listening to subscription.trial_expired, so the gate closed on its own and the email did not go out, because nobody asked for it. Step 7.

Install

Add the SDK to the app you have, or let the CLI write the webhook route and the rest of the sign-in files:

npm i @buildbase/sdk
npx buildbase init --org-id <your 24-character org id>
vibe-coding
billing
subscriptions
feature-flags
stripe

Frequently Asked Questions

Why did the payment go through but the app did not upgrade the user?

Because the payment happened at Stripe and the app found out through a message sent afterwards, or never found out at all. If the webhook that carries that message is missing, unverified, or writes a flag the browser then trusts, the plan and the feature never connect. The fix is to keep the plan where the webhook lands and check it on the server.

Can I use the payments built into Lovable, Bolt or Replit instead?

For one subscription per person, yes, and they are simpler inside the builder. As of 27 October 2026, Lovable documents one active subscription per user by default and one provider per project; Bolt and Replit connect Stripe. None of their docs cover a plan per team, seats, usage limits or feature gating, which is what this guide adds.

What stops a user from marking themselves as paid?

Keeping the plan out of their reach. If the plan is a column in a table the browser can write, or a flag an endpoint sets without checking who is calling, anyone can flip it; an audit in February 2026 found exactly that in a live app. When the plan lives on the workspace in BuildBase and the server asks BuildBase, there is nothing in your app to flip.

Do I need to store the plan in my own database?

No, and you should not. The workspace carries its plan, its trial state and its feature flags, and the SDK reads them on the client and the server. Storing a copy creates a second source of truth that drifts the first time a card fails or a trial ends.

Ship it

Create a project and run this guide against your own workspace.

Free plan7-day free trial on paid plansNo credit card required