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.
Pitchbar can let Stripe Tax calculate VAT / sales tax automatically from each customer's billing address. Opt-in, in this order — flipping the flag first breaks checkout:
CASHIER_AUTO_TAX=true # .env (default off)
php artisan config:clear
From that point every new subscription, one-off invoice,
and Stripe Checkout session carries automatic_tax;
Checkout also collects the customer's billing address and offers a
business tax-ID field automatically.
Caveats: never retroactive — existing active subscriptions keep invoicing without tax until they're individually updated in Stripe. Prices are treated as tax-exclusive by default (tax added on top at checkout); if your customers expect VAT-inclusive prices, set your Stripe prices' tax behavior to "inclusive" in the dashboard before enabling. PayPal / Razorpay flows are unaffected — Stripe Tax is Stripe-only.
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.
When a workspace's plan actually changes, the owner is emailed a
"Your plan was changed to {new plan}" confirmation
(PlanChangedMail). This is necessary because Stripe
sends nothing on a plain swap: a downgrade is a proration
credit (no payment, so no receipt email) and no invoice is
finalized at swap time (so no invoice email) — and Stripe has no
"plan changed" email at all. So without this, a customer who
downgrades hears nothing.
The email fires from the confirmed source of truth — the
customer.subscription.updated webhook
(WebhookController::syncWorkspacePlan), the moment the
workspace's plan_id flips to a different plan — so it
covers in-app swaps, upgrades, downgrades, and changes made directly
in the Stripe Dashboard. It is not sent on the initial
subscribe (customer.subscription.created) or on the many
no-op updated events Stripe fires for renewals, card
updates, and status changes (the plan hasn't changed, so no email).
Delivery is best-effort and queued: a mail failure never fails the
webhook.
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.