B Blengi docs

Run your workspace

Plan and billing

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 Settings › Plan and billing (/settings/billing; /app/billing still forwards there). 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.

WhatsApp. A plan includes the WhatsApp channel when WhatsApp channel is ticked in the plan editor, optionally with a cap on WhatsApp AI replies per month (empty = unlimited). Over the cap, WhatsApp chats go to the team in the inbox. Meta's own message fees are paid by the business to Meta. See WhatsApp channel.

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 Blengi 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 Settings › Workspace › Plan and billing (/settings/billing), the workspace owner — the only role that can open the page — can:

  1. Pick a plan from the comparison table.
  2. Get redirected to Stripe Checkout.
  3. Pay; Stripe redirects back to /settings/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 subscription button on /settings/billing opens it.

Public /pricing matrix parity

The public /pricing comparison table and the in-app /settings/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.

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.

Over the limit: stop or keep answering

At 80 % and at 100 % of a plan's monthly conversations the workspace's owners and admins get an email (once per threshold per month). What happens at 100 % is set under Settings › Billing:

  • Stop the chat (the default): new visitors cannot start a conversation until the next month; nothing extra is billed.
  • Keep answering: the chat keeps working, and every started block of 100 extra conversations is billed on the next invoice (on the 1st, as an item on the Stripe subscription's next invoice). Only with a paid subscription paid through Stripe; a workspace that loses it goes back to stopping.

An agency sets this per client on the client's page; its clients' extra conversations go on the agency's own monthly invoice.

Moving to an agency

A workspace that pays Blengi itself can become a client of a reseller agency, which then serves and invoices it. Its AI employee, knowledge, conversations, leads and installation stay exactly as they are; only who serves and invoices it changes.

  1. Under Settings › Plan and billing an owner or admin picks the agency from the partner directory, or enters the code the agency gave them, and agrees that the agency becomes the processor of the workspace's data.
  2. The agency's owners get an email and take the workspace on with the plan they put it on, or decline.
  3. The workspace's own Stripe subscription is cancelled at the end of its paid period, so nothing is paid twice. On that day (early in the morning, Amsterdam time) the workspace becomes the agency's live client; without a paid period (a trial, or the free plan) it moves at once.

Until that day the move can be cancelled on the same page: the subscription runs on as before (one you had already cancelled yourself stays cancelled). If the agency stops taking on clients before the day, the move is off in the same way. While the move waits for its day the own subscription cannot be resumed or changed here; renewing it in Stripe's portal calls the move off, so nothing is paid twice. A subscription paid through PayPal or Razorpay is cancelled first; a move from an agency back to Blengi, or to another agency, goes through the platform team.

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 Blengi" 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)

Blengi 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 /settings/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.

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.

The 14-day trial

A new account starts on the Growth trial plan: 14 days at the Growth level, with no card. That is 3 AI employees, your own branding instead of "Powered by", the Growth limits for knowledge sources, team members and integrations, API access, and every language the AI employee speaks. One limit is the trial's own: up to 300 conversations over the whole trial, also when it runs across the end of a month. At 300 the AI employee starts no new conversations until a plan is chosen.

The same trial starts for a sign-up from the website and for a prospect who activates their demo. The plan is edited in Admin › Plans like any other (rename it, change the cap or the length); it is never on the pricing page or among the plans a customer picks from. A trial account has one workspace.

DayE-mail to the owner
3, 10 and 13What the AI employee did since the trial started: conversations with visitors, new leads, the questions it could not answer yet (with a link to add the answers), and the conversations used of the 300. Each ends with Choose a plan; day 13 says the trial ends tomorrow.
7 days before the endThe plan-ending reminder, as for any plan that ends.
14The trial ends and the workspace locks until a plan is chosen; the clean-up below takes over.

php artisan trials:progress-mails sends the progress e-mails every morning at 09:00 (Amsterdam time), each one once per workspace. When a morning is missed it catches up the next day, never later. A plan a platform admin granted gets no progress e-mails. While the introductory price below applies, each progress e-mail names it too.

Introductory price

A workspace that picks a plan during its trial, or at most 7 days after the trial ended, gets the introductory price by itself at checkout: Growth for €49 a month instead of €99, for the first three months. Stripe shows the discount on the invoices of months 1–3 and charges the normal price from month 4, without anyone doing anything. It works for card checkout and for pay by invoice, once per workspace. A plan an admin grants never goes through checkout, so it never gets the price. During the trial the billing page shows the price above the plans.

The price is a coupon in Admin › Coupons, marked Introductory price. There a platform admin makes other coupons too: a percentage or an amount off, for a number of months, for some plans or all paid plans, and at most a number of uses. A coupon is made in Stripe the first time a checkout uses it and cannot change after that; switch it off and make a new one for another value. Several coupons can be marked introductory price; checkout takes the first that fits the chosen plan. An amount off is in the currency the install charges in.

The yearly offer in month 3

In the last month of the introductory price the owner gets an e-mail: when the price ends, what the plan costs from then on, and the same plan paid yearly with two months free. The yearly price is the plan's own yearly price (Admin › Plans), else ten times the monthly price. Switch to yearly opens the billing page, which shows the offer with a button for as long as the workspace can switch: the Stripe subscription changes to the yearly price at once, the rest of the month is credited, and the introductory discount ends. A plan paid through PayPal or Razorpay, or in another currency, does not get the offer. php artisan billing:annual-offer-mails sends the e-mail every morning at 09:15 (Amsterdam time), once per subscription.

Trial accounts that never pay

A trial that ends without a purchase locks the workspace: every page except Plan and billing sends the owner to choose a plan. A locked account does not stay forever:

WhenWhat happens
The trial ends, nothing paidThe workspace is locked, as before.
30 days lockedThe owner gets We will clean up your account in 14 days, with a button to choose a plan and a link to keep the account. Keeping it restarts the clock: the workspace stays locked, and the e-mail comes again 30 days later.
14 days after that e-mail, without an answerThe workspace is deleted with its AI employees, knowledge, conversations and files, and so are its users who have no other workspace. The vectors go first; one audit entry records it, without personal details.

Never deleted automatically: a workspace that paid or tried to (a card, PayPal or Razorpay subscription, pay by invoice, an add-on, a lifetime deal), one with a plan a platform admin granted, one a platform admin owns or belongs to, and one marked Never delete. The extra workspaces of a Business owner carry the owner's plan as an active subscription, so they never lock and are never deleted. Until partner workspaces exist, they are kept by their granted plan.

A workspace that never paid but is not on a trial either (the free plan of the first sign-ups) never locks, so the nightly run leaves it alone. It is on the Clean-up list too, and only a platform admin deletes it there.

Admin › Clean-up (/admin/cleanup, platform admins) is the dry run: every locked workspace, its AI employees and conversations, when the trial ended, the next step and its date, and the users who go with it. Tick rows to Delete now (this is the approval) or to mark them Never delete. Nothing is sent or deleted by itself until a platform admin switches the automatic clean-up on there; from then on php artisan trials:cleanup runs every night at 03:50 (--dry-run lists what it would do and changes nothing).

Post-checkout reconciliation

The SubscriptionReconciler service is the safety net for webhook delivery. After a successful checkout the customer redirects to /settings/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.

  1. 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).
  2. Enable in Blengi: /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. An unpaid renewal also walks the payment steps below, once a platform admin turned them on (see When a renewal invoice stays unpaid).

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 unpaid row in plan_subscriptions, written at checkout and by the subscription webhook, and flipped to active by the paid-invoice webhook. Once the first invoice is paid, a renewal of the same plan follows Stripe: active while its invoice is open, past_due once it is overdue (before, every renewal turned the row back into unpaid, as if it were a new purchase with a Cancel invoice button). A move to another plan still waits as unpaid for its own invoice.
  • 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_id error 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.

When a renewal invoice stays unpaid

For customers who pay by invoice, an unpaid renewal follows the same steps as an agency's invoice. Day 0 is the day the renewal invoice passed its due date (Stripe marks the subscription past due). From there, every morning:

DayWhat happens
0The owners and admins get a reminder pointing to Plan and billing, where the open invoice and its bank details are.
7A second reminder with the dates of the next steps, and a banner at the top of the dashboard.
14No new AI employees can be added; the existing ones keep answering.
25The notice: the chats stop in five days.
30The AI employees start no new conversations (nothing is deleted).
  • The chats never stop without the notice five days before, also when the steps start late.
  • As soon as Stripe reports the invoice paid, everything is back on at once, and the owners get an email saying so.
  • Left out: a workspace with a plan granted by the platform, a workspace owned by a platform admin, an agency's client (the agency's own invoice has these steps) and system workspaces.

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 /settings/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_addons where is_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 fluent newSubscription()->checkout() helper does NOT support mixed-mode line items.
  • A workspace_addon_purchases row is pre-written with status=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 to paid on invoice.payment_succeeded.
  • 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_slug with 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 — 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.