Run your workspace
Billing & plans
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
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. |
Monthly + Yearly variants
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:
- Stripe โ
recurring.interval = month|yearon the Price. - PayPal โ
billing_cycles[].frequency.interval_unit = MONTH|YEAR. - Razorpay โ
period = monthly|yearlyon 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.
AI rate limits
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:
-
Every visitor message records a
messagerow inusage_events.MeteredBilling::canSendMessage()sums them for the current calendar month and short-circuits the SSE stream with amessage_quota_exceedederror event when the total hitsmonthly_messages. -
MessageStreamControllerreadsmaxTokensFor()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.
Stripe sync
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.
Subscribing
From /billing, a workspace member with the
billing.manage permission can:
- Pick a plan from the comparison table.
- Get redirected to Stripe Checkout.
- Pay; Stripe redirects back to
/billingwith a success flash. - The Stripe webhook updates the workspace's
plan_id+ creates aplan_subscriptionrow.
Card on file is managed via Stripe's Customer Portal. The
Manage card button on /billing opens it.
Quotas
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.
What counts as a conversation
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.
Branding removal
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.
Invoices
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.
Lifecycle: cancel, resume, swap
The customer-facing controls live on /app/billing:
- Cancel subscription. Stripe schedules a cancel at the end of the current period (you keep access until then). PayPal cancels immediately (PayPal makes CANCELLED a terminal state). Razorpay schedules a cancel at the cycle end.
-
Resume subscription. Only Stripe, and only if the
cancel hasn't yet taken effect (still inside Cashier's
onGracePeriod). PayPal CANCELLED can't be resumed; you subscribe again. Razorpay similarly does not support resume on a cancelled subscription. -
Plan swap (upgrade / downgrade). Stripe does an
in-place swap with proration on the next invoice. PayPal and
Razorpay don't have a clean in-place swap, so clicking another
plan cancels the current subscription and re-enters checkout. The
orphan-cleanup branch of
CheckoutControllermakes sure you're never paying both subscriptions at once.
Post-checkout reconciliation
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 Stripe webhook endpoint isn't registered yet in the customer's Stripe dashboard (very common on a fresh install).
- The webhook fires but our endpoint is briefly down / the signature mismatched / it's rejected by an upstream WAF.
- The webhook eventually arrives but takes 30+ seconds, during which the customer reloads the billing page and panics.
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.
Custom plans
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.