D Diagent 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:

  • Stripe โ€” recurring.interval = month|year on the Price.
  • PayPal โ€” billing_cycles[].frequency.interval_unit = MONTH|YEAR.
  • Razorpay โ€” 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.

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.