G Growreplies docs

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:

FieldWhat it does
nameDisplay name (Free, Pro, Enterprise).
slugStable identifier — never changes after creation, even if the name does.
monthly_conversationsQuota of new conversations per calendar month. 0 means unlimited.
monthly_messagesOptional. 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_responseOptional. 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_centsPlan price in cents (charged once per interval). 0 means free / custom (skips gateway sync).
intervalBilling cadence — month or year. Defaults to month. Stripe Prices, PayPal billing_cycles, and Razorpay periods all derive from this column.
features.remove_brandingHides 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:

  • Striperecurring.interval = month|year on the Price.
  • PayPalbilling_cycles[].frequency.interval_unit = MONTH|YEAR.
  • Razorpayperiod = 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.

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 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.

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:

  1. Pick a plan from the comparison table.
  2. Get redirected to Stripe Checkout.
  3. Pay; Stripe redirects back to /billing with a success flash.
  4. The Stripe webhook updates the workspace's 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.

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.

Existing conversations and human takeovers are not gated. Only new init calls. So a visitor mid-conversation when you hit the limit can finish their thread.

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 CheckoutController makes 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.