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.
Public /pricing matrix parity
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.
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.
Automatic tax (Stripe Tax)
Pitchbar can let Stripe Tax
calculate VAT / sales tax automatically from each customer's billing
address. The easiest path is the Stripe health panel
on /settings/system (Stripe card): Run health
check → Enable Stripe Tax → enter your business
(head-office) address → save. That pushes the origin address +
tax-exclusive default onto Stripe via the Tax Settings API and flips
the app's automatic-tax flag in one step — no Stripe Dashboard
needed. The one thing that stays in the Dashboard is
tax registrations (which countries you collect in)
— a legal election Stripe requires the merchant to make (Settings →
Tax → Registrations). Stripe Tax is a paid Stripe feature, billed
per transaction where tax is calculated.
Env-based alternative (equivalent; the panel's DB flag only ever turns the feature ON, so an existing env value keeps working):
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. The one-time
add-on pay-by-invoice builds its invoice directly
(not through Cashier), so it opts into automatic_tax on
this same flag — an add-on invoice now taxes and EU-reverse-
charges VAT identically to a plan invoice instead of shipping
tax-free.
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.
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.
Plan-change email
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.
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.
Pay by invoice (bank transfer)
For EU B2B buyers whose accounting departments pay invoices rather
than cards, checkout can offer a Pay by invoice
option alongside the card flow. It uses Stripe Billing's
send_invoice subscriptions (Cashier's
createAndSendInvoice): Stripe generates the invoice,
emails it with bank-transfer instructions, calculates VAT, tracks
payment, sends reminders, and runs dunning on non-payment. No
in-house invoicing system.
- One-time Stripe Dashboard step: Settings → Billing → Invoices → payment methods — enable Bank transfer. This cannot be set via API. EUR prices are required for SEPA credit transfer (see Currency above).
- Enable in Pitchbar: /settings/system → Stripe card → "Pay by invoice" checkbox + days-until-due (default 30).
Flow: the buyer picks a plan → chooses Pay by invoice in
the payment-method picker → enters company name, address, and an
optional EU VAT number (stored as a Stripe customer tax id) →
Stripe emails the invoice. The plan activates only when the
invoice is paid — the subscription exists on Stripe
immediately (status active), but
workspace.plan_id flips on the
invoice.payment_succeeded webhook, never before. The
success-URL reconciler deliberately ignores send_invoice
subscriptions so a crafted ?checkout=success visit
cannot self-activate an unpaid plan. Non-payment runs Stripe's
dunning; the eventual customer.subscription.deleted
reverts the workspace to Free.
The billing page shows an "open invoice" banner with a prominent View invoice button linking the hosted Stripe invoice (bank details included) until payment lands. Renewal invoices follow the same send_invoice cadence automatically.
Open invoices are listed too. The billing history
(and the banner link) call
$workspace->invoices(true) — the
include-pending form. Cashier's default invoices() returns
only paid invoices, which previously hid a pay-by-invoice
buyer's single OPEN invoice entirely: the history row and the hosted
bank-transfer link both vanished until the invoice was paid. Passing
true surfaces the open invoice so the buyer always has a
clickable path to the IBAN instructions.
The invoice is finalized and emailed immediately at checkout: Stripe would otherwise hold the subscription's first invoice as a draft for a ~60-minute grace period before auto-finalizing and sending it. The checkout finalizes + sends explicitly (best-effort — on a Stripe hiccup the auto-finalization grace period still delivers it within the hour).
The invoice is emailed to the Stripe customer's email — the
workspace owner's address by default (Workspace::stripeEmail()).
Stripe customers created before this contract existed may be
email-less; the invoice flow backfills the owner's email onto them
automatically, but never overwrites an email already set on the
customer (it may be a deliberate accounting inbox changed in the
Stripe Dashboard).
Bank-transfer instructions (IBAN) on the invoice
Stripe only lets bank transfer be a default invoice payment
method for USD and GBP. For EUR (and every other
currency) it must be attached per-invoice via the API —
so the checkout sets payment_settings.payment_method_types =
[customer_balance, card] with
bank_transfer.type mapped from the invoice currency
(eur → eu_bank_transfer, gbp → gb_bank_transfer,
usd → us_bank_transfer, etc.). Without it an EUR invoice
ships with no IBAN and the bank transfer can't be paid. The
eu_bank_transfer IBAN is issued in the buyer's country
when it's a supported eurozone one (BE/DE/ES/FR/IE/NL), else the
operator's configured banking country
(STRIPE_BANK_TRANSFER_COUNTRY, default NL).
This is automatic — the operator does NOT enable anything in the
Stripe Dashboard for EUR, and does NOT put their own bank account on
the invoice. Stripe supplies the virtual IBAN and auto-reconciles
the transfer into the customer balance, which pays the invoice and
fires invoice.payment_succeeded → plan activation.
Pending-invoice safety rails
- No double subscriptions: while an invoice is
unpaid, both the card checkout AND the in-place plan swap are
blocked with an actionable error — otherwise the buyer would
end up with two Stripe subscriptions billing every cycle, or
re-price the unpaid invoice their accounting department already
received.
The pending state is tracked as an
unpaidrow inplan_subscriptions, written at checkout and by the subscription webhook, and flipped toactiveby the paid-invoice webhook. - Cancel and retry: the open-invoice banner
carries a Cancel invoice button
(
POST billing/checkout/invoice/cancel) that voids the outstanding invoice, cancels the Stripe subscription, and clears the marker so the buyer can check out again — by card or by a fresh invoice. - VAT validation: a VAT number Stripe rejects
aborts the checkout with an inline
vat_iderror before any subscription exists. An EU B2B invoice issued without the buyer's VAT id would charge VAT instead of reverse-charging it. Transient Stripe/network failures while saving the tax id do not block the invoice. - Webhook retries survive failures: if a webhook handler errors mid-processing (any gateway), its idempotency claim is released so the gateway's automatic retry re-processes the event instead of being told "already processed" — a paid invoice can never be permanently lost to a transient 500.
Stripe health panel
The Stripe card on /settings/system includes a
Stripe health panel that probes the wiring
end-to-end without leaving the app:
- Secret key — valid? Test or live mode?
- Webhook — does the auto-provisioned endpoint
exist under the current key, point at this app, carry
all required events (incl.
checkout.session.expired), and do we hold a signing secret locally? After a test→live key switch the stored endpoint belongs to the old mode — the Repair webhook button re-creates it under the new key and captures the fresh signing secret automatically. - Stripe Tax — enabled locally, activated on Stripe, head-office address set, registration count.
All probes are read-only and best-effort: failures render as amber rows with the Stripe error message, never an exception page.
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.
Add-on purchases
Visitors who pick a subscription plan at /app/billing
can optionally bundle a one-time service (Professional AI Setup, etc.)
into the same Stripe Checkout session. Selected add-ons resolve via
the addon_slug form field on POST /billing/checkout.
Behaviour:
- The controller looks the slug up against
plan_addonswhereis_active=true; unknown or inactive slugs are silently dropped so a stale link never blocks the subscription purchase. - A Stripe Price for the add-on is minted lazily on first
purchase (
StripeProductSync::ensurePriceForAddon) and cached back on the addon row. - The checkout session is created via the Stripe SDK directly
with
mode=subscription+ two line items (the recurring plan price + the one-time addon price). Cashier's fluentnewSubscription()->checkout()helper does NOT support mixed-mode line items. - A
workspace_addon_purchasesrow is pre-written withstatus=pending+ the Stripe Checkout session id before redirecting the buyer to Stripe. The session id is the idempotent anchor the webhook uses to flip the row topaidoninvoice.payment_succeeded. - On state transition to
paid,NotifyTeamOfAddonPurchaseJobdispatches theAddonPurchasedMailtoconfig('mail.team_notify_email')(set via theBLENGI_TEAM_NOTIFY_EMAILenv var; falls back toMAIL_FROM_ADDRESS). - Add-on failure (Stripe price provisioning, network blip, anything) MUST NOT block the subscription — the addon is dropped and the buyer subscribes to the plan alone. The Stripe team can re-issue the addon later from the in-workspace Services UI (next Phase 2 card).
- PayPal and Razorpay do not support mixed-mode line items;
passing
addon_slugwith a non-Stripe gateway returns a friendly error pointing the buyer at Stripe.
Add-on currency. An add-on's price is a single
currency-agnostic number; it is shown and charged in the
operator's deploy currency (CASHIER_CURRENCY) — the
"default" currency — across every surface: the Services catalog card,
the saved-card charge, the hosted Checkout (a no-saved-card buyer), and
the pay-by-invoice flow. The only override is a workspace that
deliberately picked a different currency at checkout
(preferred_currency, which defaults to usd for
every workspace, so — mirroring the currency resolver — only a non-usd
value counts as a deliberate choice). The add-on's own authored currency
is intentionally not used: an EUR deploy shows and charges EUR
even when the SKU was authored in USD (the mismatch a client reported),
and the recorded workspace_addon_purchases.currency always
matches what was charged. The hosted Checkout bills an ad-hoc
price_data line item in that currency rather than a stored
per-currency Stripe Price. (The separate bundled plan+add-on Checkout
still uses the add-on's single-currency Stripe Price.)
See One-time add-ons for managing the add-on catalog.