Billing is Stripe-backed via Laravel Cashier. Each workspace lives on one plan at a time. This page covers what plans exist, how usage is metered, and what happens when you hit a limit.
Plans are managed by platform admins (see
Plans & Stripe sync) and
visible to customers at /app/billing. A plan has:
| Field | What it does |
|---|---|
name | Display name (Free, Pro, Enterprise). |
slug | Stable identifier — never changes after creation, even if the name does. |
monthly_conversations | Quota of new conversations per calendar month. 0 means unlimited. |
monthly_messages | Optional. Per-message quota counted across every visitor turn this calendar month. Leave blank for no extra cap; the conversation count alone gates the workspace. |
max_tokens_per_response | Optional. Caps the LLM's max_tokens for every reply on this plan. Leave blank to use the default of 800. Useful for keeping the free tier short and the paid tiers verbose. |
price_cents | Plan price in cents (charged once per interval). 0 means free / custom (skips gateway sync). |
interval | Billing cadence — month or year. Defaults to month. Stripe Prices, PayPal billing_cycles, and Razorpay periods all derive from this column. |
features.remove_branding | Hides the "Powered by" footer in the widget. |
To offer an annual discount, create the same plan twice — one with
interval=month and one with interval=year — and
set the yearly price below 12× the monthly. The marketing pricing
page detects both variants and renders a Monthly/Yearly toggle.
Each gateway syncs to its own native cadence:
recurring.interval = month|year on the Price.billing_cycles[].frequency.interval_unit = MONTH|YEAR.period = monthly|yearly on the Plan.
Workspaces still subscribe to one plan row at a time (one
workspaces.plan_id), and switching from monthly to
yearly is a normal plan change — the gateway either prorates
(Stripe / PayPal) or starts the new cycle at the next billing
boundary depending on workspace setting.
The two optional cap dials (monthly_messages and
max_tokens_per_response) live under "AI rate limits"
in the plan form. They're enforced at runtime:
message row in
usage_events. MeteredBilling::canSendMessage()
sums them for the current calendar month and short-circuits the
SSE stream with a message_quota_exceeded error event
when the total hits monthly_messages.
MessageStreamController reads maxTokensFor()
once per turn (cheap — one row from the workspace's plan, on a
request the controller is already loading) and threads it through
both the tool-resolution loop and the final streaming call.
When an admin creates or updates a paid plan, the
StripeProductSync service ensures a matching Stripe Product
+ Price exists. Customers never deal with Stripe directly until checkout
— they pick a plan in the Pitchbar UI and get sent to Stripe Checkout
via Cashier.
On price changes, the old Stripe Price is archived and a new one is created (Stripe Prices are immutable). Existing subscriptions stay grandfathered on the old price; new subscriptions use the new one. This is the same behavior every Stripe-native SaaS uses.
From /billing, a workspace member with the
billing.manage permission can:
/billing with a success flash.plan_id + creates a plan_subscription row.
Card on file is managed via Stripe's Customer Portal. The
Manage card button on /billing opens it.
The public /pricing comparison table and the in-app
/billing plan cards must surface the same ten feature
rows so prospects don't see a thinner pitch than what customers
get in-product: Published agents, Monthly conversations, AI
messages per month, Workspace members, Workspaces per owner,
Knowledge sources, Workflows, Integrations, API access, Branding
removed. The
tests/Feature/Marketing/PricingMatrixParityTest
regression hard-fails CI if a row drifts off the public matrix.
The free plan caps monthly new conversations. Enforcement is on the hot
path — every /v1/widget/init call asks
MeteredBilling::canStartConversation() whether the
workspace is under its plan limit. If not:
{
"error": {
"code": "plan_limit_reached",
"message": "This workspace has reached its monthly conversation limit. Upgrade to continue."
}
}
Returned as 429. The widget's loader gracefully hides the launcher when it sees this — visitors don't see a broken state.
Every distinct conversation row counts as 1, fired by
IncrementUsageJob when the conversation's first turn
completes. Playground conversations (is_playground=true)
don't count, so the agent's owners can test freely.
Resumed conversations don't count again — only the original init bumps the meter.
Plans with features.remove_branding = true hide the
"Powered by Pitchbar" footer in the widget. The Free plan ships with
branding on; paid plans typically off. The Plan model exposes this as
$plan->removesBranding(), called at init time.
Stripe sends invoices to the billing email on file. The full history is
available in the Stripe Customer Portal (Manage card → Invoices). Cashier
also exposes $workspace->invoices() server-side if you
want to render them in-app.
The customer-facing controls live on /app/billing:
onGracePeriod). PayPal CANCELLED can't be resumed; you
subscribe again. Razorpay similarly does not support resume on a
cancelled subscription.
CheckoutController makes
sure you're never paying both subscriptions at once.
The SubscriptionReconciler service is the safety net for
webhook delivery. After a successful checkout the customer redirects
to /app/billing?checkout=success (Stripe also appends
session_id={CHECKOUT_SESSION_ID}) and the controller
pulls live subscription state directly from the gateway, flipping
workspace.plan_id in-band. The page renders the correct
plan even when:
The reconciler is idempotent — safe to call on every page load. The
webhook still does the same job whenever it lands; the two paths
converge on the same row in plan_subscriptions.
Plans with price_cents = 0 aren't free in the customer
sense — they're local-only, never synced to Stripe, and used
for hand-rolled enterprise deals or for replacing the Free plan. Admins
create them the same way; the Stripe sync simply skips.