Skip to main content

Documentation

No results found.
Features Members

Memberships

The memberships system handles Stripe-backed signup, recurring billing across multiple line items, per-page content gating, and a license-key server for verifying customers of your own software. It's off by default and adds zero overhead to...

The memberships system handles Stripe-backed signup, recurring billing across multiple line items, per-page content gating, and a license-key server for verifying customers of your own software. It's off by default and adds zero overhead to sites that don't use it. The same module powers different business models: a CMS company selling its own software, a yoga studio gating premium classes behind paid memberships, a SaaS issuing API keys to subscribers, or any combination.


When to turn it on

Turn on Memberships if you want to:

  • Sell recurring subscriptions for content, software, services, or any mix of the three
  • Gate individual pages or posts behind paid plans (paid newsletter, member-only docs, premium tutorials)
  • Issue license keys to your customers so their own software or installs can verify entitlement against your server
  • Bill multiple things to one customer (a CMS subscription + hosting add-on + monthly support time, all on one Stripe invoice)

Leave it off if your site is purely marketing-driven with no concept of paying members.

How it works

Three independent pieces, all toggleable from Dashboard → Memberships → Settings (content gating on the General tab, license keys and the reseller feature gate on the Licensing tab):

  1. Content gating — Adds a Required Plan dropdown on every Page, Post, and Content Item edit screen. When a member-required visitor hits the page, the gate redirects them to login (anonymous) or upgrade (logged in but lower tier). File-based pages get the same treatment via the page editor's Advanced section.
  2. License keys — Issues a unique 32-character key to every paying member and exposes a public /api/membership/check endpoint. External software (your own iOS app, desktop software, or another WebProCMS install) sends the key as a Bearer token, gets back {active, tier, expires_at}, and decides what to unlock locally. This is how webprocms.com sells WebProCMS itself — paying customers paste their key into their install and the six-hourly check phones home.
  3. Feature gating — Two layers. The plan gate is always on (2026-09-09): every premium feature (an opt-in key — Features::optInKeys()) runs on a client install only while its license check reports an active plan (Features::premiumLocked()); free features (default-on) are never locked, and the license server, author installs, local dev and the test suite are exempt. The owner's configured switches survive a lapse (configuredEnabled()), the Features page shows the cards as locked, and renewal turns everything straight back on at the next 6-hour poll. Because a fleet's client installs license with the agency's key, an agency that stops paying loses premium features on its own site and on every client site at once — AI chat, the assistant, the members area, all of it — with no notion of "whose key" needed on the install. The Require membership for features toggle (beside License keys) is the second, reseller-only layer: it locks every feature, free ones included, behind an active membership for installs you distribute. Install-specific rules beyond that live as custom features that subscribe to the membership state via Features::registerEnableFilter().

The subscription model

A Member has one Stripe subscription with up to three independently-managed line items, each from a different category:

Category What it sells Required?
Software Features The "license unlocks something" line — the only category whose presence flips active=true on the license-check endpoint Optional
Hosting A managed-hosting add-on, sized by traffic (Small / Medium / Large / Custom) Optional
Support Monthly support time, billed by minutes per month (e.g. Core / Pro / Elite) Optional

At least one line item is required for a Member record to exist. Hosting-only or Support-only customers are valid — they're paying customers but their license check correctly returns active=false because they don't have the Software Features line item. A self-hosted customer can have Software + Support without Hosting; a managed customer might have all three. Each line item is independent — adding, swapping, or cancelling one doesn't affect the others.

Categories are admin-managed, not hardcoded. The seeded defaults are Software / Hosting / Support, but a yoga studio install would rename them to Membership / Premium Library / Trainer Hours (or whatever) — the license-unlock flag follows the category, not its slug.

One plan per category — unless the category holds several. A category can let a member hold any mix of its plans, and several of each, for plans sold one per website, location or device. See Categories that hold several plans.

Plans

Each category has multiple plans (tiers within that category). Plans live at Dashboard → Memberships → Plans and carry:

  • Name + slug + description for display
  • Stripe price ID (paste this from Stripe Dashboard after creating the recurring price)
  • Price in cents + Billing interval (monthly or annual) — must match the Stripe price's interval. Used for display labels (/mo vs /yr) and for the custom-price minting flow.
  • Free trial (days) — when set, the register page shows an "N-day free trial" line on the plan and the embedded checkout starts the subscription with trial_period_days. Stripe reports the subscription as trialing, which maps to an active member, so access unlocks immediately; the first charge lands when the trial ends. When a signup combines multiple plans, the longest trial among them applies (Stripe trials are subscription-level).
  • Team seats (incl. owner) — how many people the plan covers. 1 (the default) means no team. See "Team seats" below.
  • Monthly support minutes (only meaningful for support-category plans, used by the future support-ticketing feature)
  • Sort order — higher sort_order = higher tier within the same category. Used for the "or higher" rule when content is gated by plan.
  • Sold per unit — off by default. Sells the plan in a member-chosen quantity instead of a flat one-per-member subscription. See "Per-unit plans" below.
  • Unit label (singular) — e.g. "task"; pluralised automatically for display ("5 tasks"). Required when Sold per unit is on.
  • Max quantity — the highest quantity a member can hold on the plan. Defaults to 20 when Sold per unit is switched on; the hard ceiling is 999 when left blank.

Monthly vs annual billing. Each plan picks one interval. If you want to offer both — say Members $20/mo and Members $200/yr — create two plans, one for each interval, and let the customer pick on the register page. Plans default to monthly. The interval flows through to the formatted display string ($20.00/mo vs $200.00/yr) and to the custom-price minting flow, so an annual plan with admin-set custom pricing also bills annually in Stripe.

Plans without a Stripe price ID ("Custom Software", "Custom Hosting", etc.) are admin-only placeholders. When an admin assigns them to a Member, the dashboard mints a one-off Stripe price for that customer at the amount specified, billed at the plan's interval. Used for comp accounts and bespoke pricing.

Per-unit plans

A plan can be Sold per unit instead of flat — think "5 tasks" rather than one flat subscription. Turning the switch on requires a Unit label (singular) and lets you set a Max quantity (defaults to 20 when you switch it on; the hard ceiling is 999 if you leave it blank). A per-unit plan can't also grant an AI credit margin — the form rejects that combination.

The plan's Price is the unit price. A member holds ONE line item on a per-unit plan with a quantity — never multiple items for the same plan — so the one-line-item-per-category rule is unchanged. Line-item money displays (member dashboard, admin member edit, the Plans pill) show the total (unit × quantity, e.g. "5 tasks · $100.00/mo"); plan lists and the visitor chatbot's plan lookup quote the unit price with its unit ("$20.00 / task / mo").

Stripe holds the quantity. Every sync — the checkout return and the customer.subscription.updated webhook — mirrors the Stripe item's quantity onto the local line item, so a quantity changed directly in Stripe (Dashboard or the customer billing portal) flows back automatically. An item with no quantity set in Stripe reads as 1. Set the Stripe price up as an ordinary recurring price, type "licensed" (per unit), same interval as the plan.

If you sell the same per-unit plan at both monthly and yearly intervals, create two plans (same name, one per interval) — a yearly subscriber can only add plans billed yearly, so a per-unit plan sold only monthly can't be added by one.

On /members/register, a per-unit plan shows a −/count/+ stepper (min 1, max the plan's max) with a live total line. ?plan={slug}&qty={n} preselects the plan and pre-fills the count (clamped to the max; 0 or junk reads as 1), and the count carries across the monthly/yearly toggle when the same plan exists at both intervals. Pricing rows and intake forms should link to /members/subscribe?plan={slug}&qty={n} rather than /members/register?plan= so an existing member is taken to the plan picker instead of being bounced.

&addon={slug}&addon_qty={n} adds a second plan from another category at that count — the Plan Finder's "Core Plan + 17 extra update tasks". It selects anything only beside a plan the link chose (?plan= naming a plan on sale — the same rule /members/subscribe forwards it by) and with a count of at least 1 (a link naming the add-on with no count selects nothing), never replaces that plan in its own category, clamps the count to the add-on's max, and opens the picker's full plan list instead of the single focused card, so the add-on being billed is in view. That opened list offers Skip for every category but the plan's own, and has no "Show fewer options", so the visitor can drop the add-on but never hide it while it is still selected.

Usage: units used this billing period (the task log)

A per-unit plan promises a count per period, and the task log records both sides of that promise. The owner logs each piece of work on Dashboard → Memberships → Members → edit → Task log (date, units — default 1 — optional minutes, what was done, and whether it counts against the subscription or is a one-off paid separately). The member sees the result on their account page under Your subscriptions: "1 of 2 tasks used this period · Renews Nov 2" with a progress bar, and a Recent work list of what was done, one-offs badged.

  • The period is the Stripe billing period, never a calendar month. Every sync mirrors the item's current_period_start / current_period_end onto the line item, so "this period" is exactly the days the member paid for — a calendar month would hand a 28th-of-the-month signup a second allowance three days later. The renewal webhook rolls the dates forward; nothing is scheduled. A line item with no stored period yet (one from before the columns existed, or a renewal whose webhook has not landed) gets a period derived by walking the plan's interval forward from its own start — never a calendar month. Days are compared in the site's timezone; a task dated on a renewal day belongs to the period that starts that day.
  • Use it or lose it. Units not used before the period ends are gone. Earlier periods stay in the log as history.
  • The count is period_quantity — what was paid for this period — not the renewing quantity. They differ for the rest of a period in which the member changed their count (see Members can change their own plan). The sync pins period_quantity to the billed count only when a new period opens.
  • One-off entries are listed, never counted. They record paid-separately work (a $35 one-off task bought through a payment link, say) beside the subscription's so the member sees everything that was done, with an optional reference. A one-off is never attached to a line item, whatever the form held.
  • Going over is shown, not refused. The log must say what actually happened; whether to do the work is the owner's call.
  • Minutes are a note, not the unit of account. A long job is logged as two units, not as sixty minutes.

Mechanics: member_task_entries (model MemberTaskEntry, activity-logged under member-tasks), the three columns on member_line_items, and App\Features\Memberships\Support\TaskAllowance (period derivation, summaryFor(), forMember(), log()). Tests: MemberTaskAllowanceTest.

Categories that hold several plans

For plans sold one per website (or per location, or per device), a category can let a member hold any mix of its plans, and several of each, on one bill: 2 websites on Core and 1 on Pro, say. Turn on Members can hold several plans on the category (Dashboard → Memberships → Plans → Categories → edit), name what members count in Unit label (singular) ("website"), and set Max per plan, the most of ONE plan a member can hold. It starts at 10 when you switch the setting on; left blank, the 999 ceiling applies.

What changes:

  • Every plan in the category is sold by count, in the category's unit and up to its maximum (MemberPlan::soldByCount()). There is no per-plan switch for it: what is being counted belongs to the category. Plan lists quote the price per unit ("$200.00 / website / mo").
  • A member holds one line item per plan there, instead of one per category. "2 websites on Core" is ONE line item at quantity 2: a Stripe subscription can't carry the same price twice, so the count lives on the item, as a per-unit plan's does. Everything still sits on the member's one subscription, so it all shares one billing interval.
  • A count here is copies of the plan, not an allowance used up each period. Unlike a per-unit plan it has no task log, and it may carry an AI margin.
  • A count of one is never spelled out. A member holding one Core site and one Pro site reads "Core Plan" and "Pro Plan", not "1 website" twice; two or more reads "2 websites · $400.00/mo".
  • Content gating takes the highest plan held in the category (see Content gating).

How changes bill — a count here is copies of a plan, so it bills the way adding or dropping a plan does, not the way a per-unit plan's count does:

  • Adding a plan the member doesn't hold there is prorated onto their subscription, at the count asked for. A second add of a plan they already hold is refused (raise its count instead). Changes to one member's plans (adds, counts, removals, moves) run one at a time, each starting from the plans as they are by then, so a double click or a second tab can't add the same plan twice or bill a copy already paid for.
  • Raising a count is prorated onto the next invoice, like adding a plan — not charged in full now, as a per-unit raise is.
  • Lowering a count takes effect at renewal, with nothing refunded: the member keeps what they paid for to the end of the period (period_quantity stays put, which is what lets a surface say "renews at 1 website"). Raising back within a count lowered earlier in the same period isn't billed again; only copies above what the period already paid for are prorated.
  • Zero drops the plan: the Stripe item is removed with no proration, so it doesn't renew and nothing is refunded, and the member keeps the plan to the end of the period they paid for. Its row stays active, marked Cancels {date}, and still counts for content gating and AI credit until then. When it's the last item the subscription renews (one already dropped no longer counts), the whole subscription ends with the period instead (Stripe refuses an empty subscription).
  • Moving one copy to another plan in the category (one website from Core to Pro) is ONE Stripe update, so it can't half-apply. Both plans must be on the same billing interval, the target must have a Stripe price and room under the maximum, and a target item the member holds must not be ending. It's prorated (the moved copy is credited on the source and charged on the target), except into a plan that already has a copy paid for this period: then nothing is billed now, and the source renews one lower while keeping the copy it paid for, as a lowering does. At a count of one the source item is removed.
  • Swapping an item to another plan in the category keeps its count, and is refused onto a plan the member already holds there (move copies instead).
  • The sync writes one row per Stripe item, mirrors each count, and soft-cancels an item that is gone, except a dropped plan whose paid period hasn't ended: the first sync after that (the renewal's customer.subscription.updated) cancels it.
  • AI credit: a paying member earns a plan's credit once per copy paid for this period, so a lowering's copies keep their credit until renewal, like the copies themselves. A comped site drawing on a billing account gets ONE of the account's plans in such a category, never the sum or the count — see Managed AI credits.

At signup (/members/register) the count is the selection:

  • Each plan is a count row (− / count / +) starting at 0, meaning not included. There is no radio and no Skip: leaving every count at 0 skips the category. The price reads "per website", and the "2 websites × $200.00 = $400.00/mo" total shows once a count is above 0. Styled by the picker's Count Row Classes (no pointer or hover: the row isn't one click target, as a radio row is).
  • A pricing link starts its plan at its count. ?plan={slug}&qty={n} (or the subscribe entry URL) starts that plan at n, default 1, clamped to the category's maximum. The focused card shows the plan with its count and a Monthly/Annual toggle that moves the count onto the same-name plan in the other interval (Core monthly 2 → Core yearly 2), so only one of the pair is ever billed. See all plans lists the category's other plans at 0, to be raised to add them. &addon={slug}&addon_qty={n} naming a plan in such a category starts it at n the same way.
  • The full list's Monthly/Annual toggle moves counts too: every counted plan with a same-name sibling hands its count to the sibling, and a plan sold in one interval only keeps its count.
  • Checkout gets one line per plan, at its count. Submit refuses a count outside 0 to the maximum; a blank count reads as 0, since nothing on the page said it was included. A count posted for a plan that isn't on sale in such a category is never read, and neither is a radio value posted for the category, so neither can put a plan on the bill.
  • All at 0 with nothing else picked is an empty selection, refused on a memberships-only install as always ("Pick at least one plan").

On the member's Plans page (/members/upgrade) the category is ONE card, listing each plan the member holds there:

  • What it bills: the plan name and "2 websites · $400.00/mo" (one copy reads just the price). After a lowering it shows the count paid for this period, plus "Renews at 1 website on Nov 14".
  • Change: a count stepper (1 to the category's maximum) and two clicks. A raise is prorated, a lowering renews lower; the confirmation says so.
  • Remove: drops that plan, every copy of it, in two clicks. Nothing is refunded and the member keeps it to the end of the period; the confirmation names the date, and until then the card lists it as Cancels {date} with nothing to change on it (it can be added again once it has ended). Not offered on the last item the subscription renews, because that would end the whole subscription: the card says to cancel from the dashboard instead, where the cancel flow carries the save offers. A plan already removed this period doesn't count, so removing Pro beside Core leaves Core as the last.
  • Move one to {plan}: one button per other plan in the category on the same billing interval, on sale, with room under the maximum. Two clicks; one Stripe update (see above). Both plans' steppers then show their new counts, so a Change straight after can't quietly put the moved copy back.
  • Add a plan: the category's plans the member doesn't hold yet, on their subscription's interval, each with a stepper (from 1) and Add (two clicks, prorated). A category they hold nothing in keeps the usual Add {category} card, with a stepper on each plan.
  • No Switch. The member may hold the target already, and Move one does the same job without touching the other copies. A switch posted for a plan in such a category is refused.
  • Every id and count is re-checked on the acting click: the item must be the member's own, active, on Stripe and not ending; a move's target must be in the same category, on the same interval, on sale with a Stripe price, and have room; an add must be on the subscription's interval (this one now applies to every category) and not a plan already held there; a count must be 1 to the maximum. A plan in a category the owner switched off is not on sale: the signup page refuses one posted by id, and the Plans page offers it only to a member who already holds a plan in that category.
  • A link pre-fills, never arms. ?plan={slug}&qty={n} fills the held plan's stepper when they hold it, or its Add a plan row when they hold other plans in the category (an add, never a swap), or the Add card when they hold nothing there. The count is clamped to the category's maximum.

The owner gets an email after every change a member makes there: add, raise, lower, remove and move ("[Site] Dana Owner changed their websites"). It goes to the Fleet alert address (Fleet → Settings, fleet.alert_email, the same address the managed-AI usage alerts use), or to every admin when that is empty, and lists each plan's count before and now, the member's name, email and business, and a link to their admin page. A row whose website is going away (a lowering or a removal, never a move) says the date it is paid through, which is when to take the site down. The email is a diff of what the member holds before and after, taken around the change, so it reports what actually happened: a change Stripe refused sends nothing, and one that failed half-way reports what did move. A mail failure is reported and never fails or undoes the change. Not sent for signups.

Refused, so the category always describes what its members hold:

  • on a license-unlocking category, from either form: the license reports one plan;
  • while the category sells a per-unit plan (move it, or turn off its Sold per unit, first). A per-unit plan can't be saved into such a category either;
  • turning the setting off while any member holds more than one plan there, or more than one of a plan;
  • a maximum below a count a member already holds;
  • for any plan, moving it into a one-plan category while a member holds more than one of it, or already holds another plan in that category.

Mechanics: member_plan_categories.allows_multiple / unit_label / max_quantity, MemberPlanCategory::allowsMultiple(), and on MemberPlan soldByCount() and takesQuantity() (per unit OR by count — the question every count-taking surface asks). Billing: MemberLineItemChanger (addLineItem, setQuantity, moveOne, swapLineItem) and MembershipsStripe::updateSubscriptionItems(). Signup: the register component's quantityByPlan (0 = not included) and the picker's count rows (x-dl.member-plan-picker + the stepper partial's minQuantity: 0 variant). Plans page: the upgrade component's setQuantity, removePlan, moveOne and addPlan, each wrapped so the owner is emailed (MemberWebsiteChangeNotifier::snapshot() before, ::notify() after; it reads held items by their plan's category, never the row's category slug copy, which lags a renamed category until the next sync). "Last item the subscription renews" is one rule, MemberLineItemChanger::isLastRenewingItem() over MemberLineItem::renewsOnStripe(), shared by the changer and the page. Tests: MemberMultiPlanGroupTest, MemberMultiPlanBillingTest, MemberMultiPlanSignupTest, MemberMultiPlanUpgradeTest.

Signup flow

  1. Guest visits /members/register and selects one plan per category — a per-unit plan shows a stepper to choose its quantity, and a category that holds several plans takes a count on each plan instead (see Categories that hold several plans)
  2. They enter email + password + (optional) name and submit. A selection that mixes monthly and yearly plans is refused here, before the account exists: everything lands on one Stripe subscription, which bills on one interval. A tax-exempt organization can tick a box and attach its exemption certificate (see Selling to a tax-exempt buyer → Claimed at sign-up)
  3. The form creates the Member record locally with status=active and no Stripe IDs yet
  4. A Stripe Customer is created via customers.create, and the Member's stripe_customer_id is filled in. A claimed tax exemption is applied to it here, before any checkout exists
  5. An embedded Stripe Checkout session (ui_mode: embedded, same UI as the shop checkout) is created with one line_items entry per selected plan, member ID embedded in client_reference_id and metadata.member_id. If steps 4–5 fail (Stripe refuses or can't be reached), the account already exists, so the buyer is signed in and sent to their Plans page (/members/upgrade) with a message; choosing a plan there opens the same checkout
  6. The guest is redirected to /members/checkout, where Stripe's payment form renders embedded on-site (the session's client secret is carried over in the HTTP session)
  7. On success, Stripe returns them to /members/checkout/success?session_id=…
  8. The success controller fetches the Checkout session (with subscription + items expanded), calls MemberSubscriptionSync::syncFromStripeSubscription() to write the line items locally, generates a license key if license-keys mode is on, and logs the Member in
  9. The same sync also runs from the checkout.session.completed webhook — idempotent on stripe_subscription_item_id so it's safe to run both. Every feature's checkouts reach that endpoint (shop orders, donations, invoices, payment links), and none of them carry a member_id; the webhook warns about a missing member_id only for a subscription-mode session stamped for THIS install — a membership checkout of ours that lost it — and ignores the rest quietly.

If the user abandons Checkout and returns later, they're a Member with no active line items. They can log in normally but see "no subscriptions" on their dashboard, with a "Browse plans" link back to register.

Coupons work out of the box: the embedded checkout is created with allow_promotion_codes, so any promotion code minted in Stripe Dashboard can be entered in the payment form.

Failed payments (dunning)

When a renewal charge fails, Stripe's smart retries take over the charging cadence, and the CMS layers member-facing communication on top:

  • Each invoice.payment_failed webhook pauses the member (existing behavior), advances a dunning stage (1 → 2 → 3, capped), records Stripe's next retry time from the invoice, and emails the member an escalating notice — stage 1 is a gentle heads-up, stage 2 a firmer reminder, stage 3 a final notice that the subscription will cancel if the payment keeps failing. Every email links to the billing portal to update the card.
  • The member's own dashboard shows a banner ("Your last payment failed… we'll retry on {date}") with an Update payment method button; the admin's Members list shows a red Past due N/3 badge with the retry date.
  • A successful payment resets the episode: stage back to 0, retry timestamp cleared, member active again.
  • Emails send through the Marketing mailer and can be switched off under Memberships → Settings → Retention & failed payments.

Cancellation-save flow

Members cancel from their own dashboard ("Cancel subscription" under Your subscriptions) — and before the cancel goes through, the flow offers two alternatives:

  • Pause instead — pauses Stripe billing (pause_collection, invoices voided) for 1–N months (admin-configurable cap, default 3). Access pauses with the billing: the sync maps a paused-collection subscription to member status paused even though Stripe still reports it active. The member's dashboard shows "Paused until {date}" with a Resume now button that ends the pause early; otherwise billing resumes automatically on the chosen date and the next paid invoice reactivates access.
  • A discount offer — the admin configures a Stripe coupon ID + a display label (e.g. "50% off for 3 months") under Memberships → Settings → Retention & failed payments. Accepting applies the coupon to the live subscription instantly. One redemption per member, tracked on the member record.
  • Cancel anyway — cancels at period end (access continues until the paid time runs out), stamping the end date on the member's line items so both dashboards show "Cancels {date}".

Every outcome — pause, resume, discount, cancel — is recorded in member_retention_events with the member's optional free-text reason, so you can see why people leave and whether the save offers work.

Team seats

A plan whose Team seats is above 1 turns its subscribers into team owners: one subscription, N people.

  • The owner's member dashboard grows a Your team card showing seat usage ("2 of 5 seats used") with an invite form (email + optional name). Inviting creates the teammate's member account immediately, attached to the owner, and emails them a set-your-password link (same reset broker as forgot-password). No payment step — their access rides the owner's subscription.
  • Content gating evaluates seat members against their owner: they get whatever plan access the owner's active line items grant, and lose it the moment the owner is paused or cancelled. The seat member's own status still applies too, so one seat can be suspended individually.
  • Removing a teammate (owner dashboard or admin) detaches them — the account survives as a free member account that can subscribe on its own. Deleting an owner detaches all seats the same way.
  • Seat members see "Your access is provided by {owner}'s team subscription" on their dashboard and have no billing controls of their own. License keys remain individual — a seat member does not inherit the owner's license-unlock entitlement.
  • The allowance is the highest seats value among the owner's active plans; the admin Members list marks seat accounts with a Seat badge.

Member-facing pages

Every page below uses the public layout so it matches the site's branding:

  • /members/login, /members/logout, /members/register
  • /members/forgot-password, /members/reset-password/{token}
  • /members/dashboard — current line items, license key (with copy + regenerate), Manage Billing button, passkeys, and a Change password card (current password, then a new one under the member password rule, or the staff policy for a staff account; it signs other browsers' remember-me out, and Forgot it? signs the member out to the reset request). If a sign-in found the member's password in a known breach, the dashboard leads with a notice pointing at that card — never a block. See Login Protection.
  • /members/billing — redirects to Stripe's Customer Portal for self-service payment-method updates
  • /members/upgrade?plan=… — the member's own plan picker (switch, add, or change a per-unit plan's quantity; in a category that holds several plans, change, remove, move or add them), and where a member on a lower tier lands when they hit gated content
  • /members/subscribe?plan=…&qty=…&email=…&name=… — the entry URL for pricing rows and intake-form redirects; sorts a member to the plan picker and a guest to signup (see below)
  • /members/installs — My Installs (license server only): every install checking in under the member's key, plus their seat members'; on a site-capped plan, "N of 5 sites in use" and a Release link per install that holds a slot (see Releasing a site)

Members can change their own plan

/members/upgrade (the Plans pill) lists, for each category, the plans the member could switch to or add, each with its price. The page is titled Plans; it reads Upgrade required only when a members-only page sent the member there (?plan= alone). A member can switch the plan they hold within a category, add a plan in a category they hold nothing in, or change the quantity on a per-unit plan they hold — all through the same service the admin Member screen uses. Switching and adding are immediate and prorated. A quantity change is never prorated, in either direction, because Stripe's proration would price a unit added on the last day of a period at a few cents and refund units already used:

  • Adding units is charged now, in full, at the unit price, for the units above what the period is already paid for — one standalone Stripe invoice, paid on the spot off the subscription's payment method with automatic tax on — and the units are usable at once (period_quantity rises with the renewing count). Stripe refuses a recurring price on an invoice item, so the line restates the plan's Stripe price one-off (same product, currency, unit amount and tax behaviour — a price stating no tax behaviour is charged tax-exclusive). A price with no single unit amount (tiered) can't be restated that way: the subscription's count has already been raised by then, so the charge is refused before any invoice is created, the raise is put back on Stripe, and the member is told to contact you — nothing changes locally. Raising back within a count lowered earlier in the same period charges nothing; a paid count from a period that has ended never counts. An invoice that a credit balance or coupon settles at finalize is not paid again. If the charge fails the count is put back on Stripe and nothing changes locally; the invoice is voided (or the draft deleted) so the member is never dunned for it. Only a refused payment (a declined card, an invoice left unpaid) tells the member to update their card; any other failure, or a count that could not be put back, tells them to contact you and is reported. After any failure the page's stepper and total go back to the count the member actually holds. The webhook ignores these invoices (metadata member_unit_purchase) for pausing, dunning and affiliate commission alike. (For developers: the opt-in stripe-live contract tests run this charge against Stripe's test mode — see ecommerce.md → Architecture notes.)
  • Removing units takes effect at renewal. Stripe bills the lower count from the next invoice, but the member keeps the count they paid for through the current period.
  • The Stripe customer portal opens with Stripe's own portal configuration. If it allows quantity changes, turn its proration off there too (Stripe Dashboard → Settings → Billing → Customer portal); the task log's paid count is protected regardless, but the money side of a portal change follows that setting.

Deliberate limits, because every plan id (and quantity) arrives from the browser:

  • Adding needs a Stripe subscription to attach to. A member can add a plan only in a category they hold nothing in, prorated onto their existing subscription — but only if they have one. An account with no Stripe subscription at all (a free or comped account, a lapsed one, or staff) gets Subscribe on the same card instead, which opens the normal embedded checkout; nothing bills until they pay there. Staff who have never opened their account area get their member profile created when they first open this page, as the account dashboard does.
  • Only plans Stripe can bill are offered — a plan with no Stripe price would fail inside the changer, so it never appears.
  • Two clicks. The first arms a confirmation and explains the proration; the second acts. A plan or quantity change bills immediately, so it is never one stray click away.
  • Every guard is re-checked on the acting click, not just when the list renders — the category the member holds, the plan still being active, and it not being the plan they are already on. For a quantity change: the item must belong to the signed-in member, the plan must still be per-unit and active, and the item must be a Stripe-backed item that isn't already scheduled to cancel.
  • Failures stay generic. A Stripe error names subscription ids and plan slugs; the member sees a message telling them to get in touch instead.
  • A held per-unit plan gets a Change quantity control — the same stepper, starting at the current count. Two clicks, same as a switch; the confirmation explains that adding is charged now and removing waits for renewal. The allowed range is 1 through the plan's max: the page refuses 0 (to stop the plan altogether, the member cancels or contacts you), a count above the max, and the count they already have.
  • Admin-comped items (no Stripe item) show their quantity but can't be changed here — that needs a Stripe-backed line item.
  • /members/upgrade?plan={slug}&qty={n} is a purchase intent. Holding nothing in that plan's category pre-fills the add card's stepper at N; already holding that plan pre-fills Change quantity at N. Nothing is armed: a link anyone can send never makes the member's first click a billed change. ?plan= alone keeps its existing meaning — the "this content needs plan X" banner a gated page redirects to.

The subscribe entry URL

A pricing CTA or a form's redirect can't know whether the visitor already has an account, and /members/register is guest-only. /members/subscribe?plan=…&qty=…&email=…&name=… (SubscribeEntryController) exists to sort the two: the route carries neither guest nor auth middleware, only the memberships feature gate. A signed-in visitor is redirected to /members/upgrade?plan=&qty=; a guest to /members/register?plan=&qty=&email=&name=.

The count is always forwarded, default 1 — on the plan picker a count is what marks a purchase intent and suppresses the "reserved for members on plan X" banner that a bare ?plan= still means. It's clamped to the plan's max (for a plan in a category that holds several, the category's), and a flat plan always forwards 1. Only whitelisted keys pass through (plan, qty, email, name, and for a guest addon + addon_qty); a slug that names no active plan drops the whole query, so neither page preselects a plan it can't sell. An add-on is forwarded only when it names another active plan and a count of at least 1 (clamped to its max); a signed-in member is sent to the plan picker with the plan alone, because the picker adds one plan at a time. On the register page, email and name prefill only an empty field.

A link that goes straight to /members/register?plan=… still works for a signed-in visitor, staff included: instead of bouncing them to a dashboard, the guest-only redirect forwards the plan and count through /members/subscribe to the plan picker. Signup with no ?plan= still sends a signed-in visitor to their dashboard. Linking to /members/subscribe directly is still preferred, because it saves that hop.

This is the canonical CTA target for pricing rows and for a Form Builder redirect — e.g. an intake form whose confirmation redirects to /members/subscribe?plan=monthly-updates&qty={task_count}&email={email}&name={name} (see merge tags in the redirect URL).

The account area (one nav across every feature)

/members/dashboard is the account home, and resources/views/partials/account-nav.blade.php is the pill nav included at the top of every account page. Each pill appears only when its feature is on, and every list matches records by the signed-in account's email — the feature's own records (clients, donors, bookers) and member accounts are separate systems, so email is the join.

Pill Route Feature
Saved Homes / Saved Searches real-estate.saved-* real_estate
Invoices /members/invoices client_invoicing
Agreements /members/agreements client_invoicing (proposals + contracts)
Files / Approvals /members/files, /members/approvals client_portal
Projects /members/projects project_tracking
Appointments /members/appointments online_booking
Event Tickets /members/event-tickets event_ticketing
Donations /members/donations donations
Support /support/mine tickets
Plans /members/upgrade memberships
My Installs /members/installs license server (Release on capped plans)

Each list page is owned by its own module (route in the module's route file, view in app/Features/{Name}/resources/views/public/⚡my-*.blade.php), and each links out to the detail page that already existed — the tokenized project tracker, the booking manage page, the ticket order page, the proposal/contract signing page. Those tokenized pages stay the single implementation of every detail view and every action (reschedule, cancel, sign, accept); the account area is navigation to them, not a second copy of them.

The account dashboard mirrors the same set as summary cards, each shown only when the member actually has records — projects underway, the next appointment, ticket orders on file, gifts and lifetime total, proposals and contracts.

Confirmations. An account page's action reports its result the way the dashboard does, with a notify event ("Website Core is now 2 websites."), shown at the bottom of the screen for a few seconds. The listener is x-notify-toast, which the public layout renders beside @livewireScripts — so on every public page that loads the Livewire runtime, the only pages that can dispatch notify (the product page's "This item is out of stock.", a donor's "Could not cancel the gift…", a rescheduled appointment, as well as the account area). A slim static page renders no runtime and no listener.

Two consequences worth knowing:

  • The account HOME is gated separately from the pills. /members/dashboard only exists when a MemberAccounts::FEATURES feature is on (memberships, ecommerce, real estate, courses, client portal), while e.g. /members/invoices rides client_invoicing. The nav filters itself with Route::has() so an invoicing-only install renders the pills that exist instead of fataling on a missing route.
  • These pages are never row-editable. They're per-visitor logged-in lists, so VoltFileService::discoverFeatureModulePages() skips them from the page editor's picker the same way it skips the member dashboard.

Members log in via the members auth guard (separate from the CMS admin web guard). An admin user logged in as a CMS Admin is not automatically a Member — they're separate identities.

Content gating

When content gating mode is on, the Content Item edit screens grow a Required Plan dropdown that lists every active plan grouped by category. Selecting a plan on a piece of content gates it: anonymous visitors get redirected to login, members on lower plans get redirected to upgrade, members on the required plan (or higher in the same category) get through.

For file-based pages (the pages created by the page editor), gating is set in the editor's Advanced section under "Require member subscription" — same dropdown shape, slightly different storage. The plan slug is encoded as ->middleware('page.member-plan:plan-slug') on the page's route in routes/web.php, and the middleware enforces per request. Member-required pages are forced uncached so the gate's redirect can't be served from cache to other visitors.

For model-backed content (ContentItem — blog, and every other content type), the required plan ID is stored on the record itself and enforced in the page's mount() via MemberContentAccess::enforce().

The "or higher" rule uses member_plans.sort_order within the same category. In a category that holds several plans, the highest plan the member holds there decides. A page requiring Support Core (sort_order 10) lets Support Pro (20) and Elite (30) members through but blocks Support Core (10) members — wait, that's wrong, equal-or-higher means equal-or-higher: Support Core sees a Core-gated page just fine; a Hosting Small member doesn't see it (wrong category). The same category requirement is what makes "or higher" meaningful — a Pro support customer is implicitly entitled to whatever Core sees.

License-key server

When license-keys mode is on, every Member with a software-category line item gets a unique 32-character key on their dashboard. They paste it into the consuming software (most commonly: another WebProCMS install, via the install's CMS_LICENSE_KEY env var or its Settings → Updates & License page).

The consuming software POSTs to /api/membership/check with:

  • Authorization: Bearer {their_license_key}
  • Body: {"host": "their-domain.com", "version": "1.2.3"} — a WebProCMS install also sends its stable install_id (a per-install UUID) and tier_ladder: 1, along with its telemetry blocks

And gets back:

{
    "active": true,
    "tier": "members",
    "expires_at": null,
    "licensed": true
}

The endpoint is rate-limited to 60 requests per minute per IP and always returns 200 (even for unknown keys or inactive members) so a deauthed install can't drift indefinitely by polling and getting nothing back. The tier field returns the slug of the active software-category plan — the consumer can use it to differentiate behavior between subscription levels. licensed says the key was recognised (free or paid) — and, on the WebProCMS license server, that the install fits its plan's site limit (below).

Every successful key match writes last_seen_at, last_seen_host, and last_seen_version to the Member record — even for cancelled members — so admins can spot deauthed installs still phoning home (security signal).

WebProCMS's own tiers

webprocms.com sells WebProCMS on four tiers. The slug is what the license check returns as tier. TierMatrix holds each tier's name, tagline, feature bullets, site limit and call to action for every surface that shows plans — the pricing page's live tiers row, the Your Plan card on Settings → Updates & License, the member edit page's Comp as select — so they can't drift apart. Prices come from the license server's software plan rows; the figures below are the defaults.

Tier Slug Price Sites What it adds
Core core Free 5 The full CMS with automatic updates; premium features off. Also the tier to drop to when pausing a paid plan — the site stays up and updated.
Business business $25/mo 5 Every premium feature, installing custom feature modules, and removing the "Powered by WebProCMS" attribution.
Agency agency $50/mo Unlimited Fleet Management, source-code access (self-host and modify), and the developer tooling for building your own feature modules, rows and primitives.
Enterprise enterprise $1,000/mo, 12-month minimum Unlimited Everything in Agency plus our team: content types and workflows built for you, a feature queue worked one request at a time (a second lane costs extra), a one-time import of your old content, editor training, a monthly SEO and accessibility review with the fixes applied, and basic hosting (extra for high traffic) and AI.

A higher tier includes everything below it. config('cms.tier_ladder') ranks the slugs (core < business < agency < enterprise), and a tier-locked gate passes for its tier or any tier above it — Membership::meetsTierRequirement() for a feature's requires_tier (Fleet Management requires agency), Membership::hasDeveloperTier() for cms.developer_tier (default agency, env CMS_DEVELOPER_TIER). A slug the ladder doesn't list — a plain comp's exempt, a typo, a tier from a newer build — still unlocks the member features but no tier-locked one, and a requirement the ladder doesn't list is met by nobody. The license server and author-mode installs pass every tier gate.

Enterprise is sold by conversation. Its pricing card says Book a call instead of Get Started, pointing at https://www.webprocms.com/book-a-call (TierMatrix::callUrl()); set the licensing.enterprise_call_url Setting on the license server to send it elsewhere — a value that isn't an http(s) URL falls back to the default. Each tier also has a getting-started article (MemberOnboarding) on the member's account dashboard and in their welcome email — on the license server (and author-mode installs) only, and only where the plan has no copy of its own.

The slugs are a wire contract. Every shipped build compares the reported tier against config baked in at build time, so renaming a slug strips entitlements from every install that hasn't updated. The one rename so far (2026-10-08: pro → business, enterprise → agency, with a new enterprise above them) shipped with a migration that renamed the plan rows and re-pinned comps, and with the legacy names below for builds that predate it. The migration runs on every install, because every install carries the seeded software plans whether or not it sells anything; on an install that switched those plans off to sell its own, the added Enterprise plan is switched off with them (a follow-up migration, which leaves the row alone once it has a Stripe price, a changed price or a subscriber).

On the WebProCMS license server: tier names and the site limit

Two rules apply only on the install that licenses WebProCMS itself (CMS_IS_LICENSE_SERVER). An install selling keys for its own software, or an agency's fleet head, gets neither — their plans and members aren't WebProCMS tiers.

Tier names for older builds. A check-in that sends tier_ladder: 1 (every build since the four-tier ladder) gets the plan slug as stored: business, agency, enterprise. One without it — a build older than the ladder, or third-party software — gets the names those builds compare by equality: business → pro, agency and enterprise → enterprise. A comp pinned to a tier is renamed the same way, which is what keeps a not-yet-updated fleet head (pinned agency) on its Fleet tier. The map is MembershipCheckController::LEGACY_TIER_SLUGS; delete it once no pre-ladder install checks in.

The site limit. Core and Business activate five sites (TierMatrix::siteLimitFor()). Agency, Enterprise, a comp (whatever tier it is pinned to) and any plan slug the matrix doesn't list are unlimited; a recognised key with no active plan takes Core's limit. The count is the member's member_installs rows seen in the last 30 days (MemberInstall::SITE_LIMIT_WINDOW_DAYS), so a site that stops checking in frees its slot a month later without anyone releasing it — or at once, when the member releases it (below).

  • A new install (no row for its install_id) arriving when the count has reached the limit is answered licensed: false, active: false, tier: null, reason: "site_limit" — and its row is not recorded, so it activates by itself at a later check once a slot frees or the plan is upgraded.
  • An install that already has a row is never refused, even past the limit after a downgrade or a lapse; the answer carries over_limit: true for information. A row that went quiet and comes back is that install returning, not a new one — until memberships:prune-installs deletes it after 180 days of silence (MemberInstall::RETENTION_DAYS), after which it comes back as a new install and takes its turn against the limit.
  • Fails open. No install_id (clients older than it) or any error while counting lets the install through. Two new installs checking in at the same moment on the last free slot can both get in — the cap is advisory at that edge.
  • A capped license gets site_limit and site_count on every answer. reason, site_limit, site_count and over_limit ride inside the signed claims, and the client reads them only from there.

Releasing a site. A member who has taken a site down, replaced it, or activated a dev copy with the key doesn't have to wait 30 days for its slot. My Installs (/members/installs) shows "N of 5 sites in use" on a capped plan and a Release link on each of the member's own installs that holds a slot; the owner can do the same on the member's behalf from the Sites card on the member edit screen. Both run SiteSlots::release(), which deletes the install's member_installs row — what memberships:prune-installs does after 180 days, done early — so the count drops at once.

  • A released site that keeps checking in is a new install again. With no row, its next check-in takes its turn against the limit like any new install: refused (reason: site_limit) while the plan's sites are all in use, so saving pages and CMS updates stop on it; admitted, and recorded again, if a slot is free. Releasing therefore never lets a plan run more sites than its limit. Taking the key off doesn't keep such a site working either — a production install with no key is unlicensed too. (An APP_ENV=local copy is never gated on its own side, so releasing a dev copy costs it nothing.)
  • This is the one sanctioned exception to "an activated site is never refused". The member's release is their explicit consent to that site losing its slot; nothing else — a downgrade, a lapse, a full plan — ever refuses an install that already has a row. The confirm dialog says so in plain words before anything is deleted.
  • Only where it frees a slot. Both sides offer Release only on a capped plan and only on an install that holds a slot (seen in the last 30 days — SiteSlots::holdsSlot()). An uncapped plan, or a site already quiet past the window, has no slot to free; releasing it would only delete the row until the next check-in re-created it. A release also drops whatever was set on that row from the Fleet page (update-speed override, server grouping, backup history); the admin confirm says so.
  • Scoped and idempotent. The member side releases only the member's OWN rows (a seat member's installs stay read-only, and another member's id is a 404). The admin side is scoped to the member being edited and runs only on the license server. The target row id is a #[Locked] Livewire property set server-side after the ownership check; a second confirm finds nothing pending, and a row released meanwhile reads as "already released".
  • A member may release at most their plan's site limit in any 30 days (SiteSlots::memberReleasesLeft(), window RELEASE_WINDOW_DAYS). Without that cap the limit could be defeated: a released site stays activated on its own side until its next check-in (up to six hours), so releasing each site right after it checks in kept the count near zero and every site admitted. My Installs shows "Releases left: N of 5 in any 30 days"; at zero the Release links go away and the page says when the next one opens. The owner's releases from the member edit screen are not limited and don't count. Releases are serialized per member (a row lock), so two at once can't both take the last one (owner decision, 2026-10-08, replacing an earlier "no cooldown" call that assumed only five sites could be active at once).
  • Every release is recorded. A row in member_site_releases (member, install id, host, released_by member/admin, acting user — the last five show on the Sites card) and a memberships: site released log line. memberships:prune-installs deletes ledger rows after the same 180 days as install rows.
  • Dev copies count. A *.test / localhost install is counted like any other site: the host is self-reported, so exempting it would hand out free slots to anyone who set APP_URL to a .test name (owner decision, 2026-10-08). The member releases a dev copy instead — though while a slot is free, a dev copy still running with the key takes one back at its next check-in.
  • A release racing an in-flight check-in can re-create the row; the window is milliseconds, advisory like the last-slot race above.

On the install, MembershipClient stores membership.license_rejected_reason (cleared with the refusal) and membership.site_limit / membership.site_count (always written, so an uncapped plan clears them). Settings → Updates & License shows "N of 5 sites in use" on the plan card, and a refused install gets a Site limit reached box with the pricing link and a My Installs link (TierMatrix::INSTALLS_URL) instead of the activation prompt; the editor's activation modal and cms:check-membership say the same. A refused install activates at its next check-in after a slot frees — Check Now on Updates & License runs one straight away.

What "license-unlock" actually unlocks

The license endpoint returns active=true. What that boolean means is up to the consumer. The CMS doesn't bake any specific behavior in. webprocms.com interprets it as "premium CMS features unlock"; a yoga studio's iOS app could interpret it as "Premium Library content unlocks"; a SaaS vendor could interpret it as "the SDK works."

On a consuming WebProCMS install, the install runs cms:check-membership every six hours (a LazyCron task; the license server itself skips it). The command writes Setting::set('membership.is_member', $active) and Setting::set('membership.tier', $tier) based on the response. Anything in the codebase can then read Membership::isMember() to decide its behavior.

For reselling WebProCMS specifically, the Require membership for features toggle (Memberships → Settings → Licensing, Setting key memberships.gate_features, off by default) adds a runtime filter to Features::enabled() that vetoes feature enablement when ! Membership::isMember(). It is turned on for distributed installs during provisioning. Other Member-feature installs leave it off, and their Features page works without membership.

Configuration

Per-install settings (Memberships → Settings)

The page is split into tabs, each linkable by hash (#payments, #licensing, #managed-ai):

  • General — content gating on/off, and the signup-form fields (require a name, ask for a business name, ask for a phone number)
  • Payments — the Memberships Stripe webhook signing secret, and Retention & failed payments: failed-payment email toggle, save-offer Stripe coupon ID + member-facing label, and the longest self-serve pause (months)
  • Licensing — license-keys mode on/off, Require membership for features, and (with license keys on) CMS release distribution
  • Managed AI — only while license-keys mode is on; the brokered text, image and embeddings upstreams (see Managed AI credits)

This install's own license — the key its six-hourly check sends and, on an owner install, the license-server URL — is on Settings → Updates & License, with the Diagnostics sharing switches beside it. They apply to every install, so they live on a page that exists whether or not Memberships is on:

  • License key — what this install's six-hourly check sends as its Bearer token. An unlicensed install pastes it into the activation box; a licensed one replaces it under Replace license key (checked straight away). Overrides CMS_LICENSE_KEY when set; remove the stored value to fall back to env.
  • Membership API URL — owner installs only (license server / author mode): where this install phones home. Overrides CMS_MEMBERSHIP_API_URL. Leave blank to fall back to env (defaults to https://www.webprocms.com/api/membership/check). A client install always checks the fixed URL.

Stripe credentials

Preferred: dashboard settings, which take precedence over env.

  • Site-wide key pair — Dashboard → Settings → API Keys → Stripe Keys (stripe.key / stripe.secret). Shared by every payment-taking feature.
  • Webhook signing secret — Dashboard → Settings → Memberships (stripe.webhook_secret.memberships). Stripe issues one signing secret per webhook endpoint, so each feature that registers its own endpoint keeps its secret on its own settings page, namespaced as stripe.webhook_secret.{feature}.

Env fallback (used when the matching Setting is blank — handy for headless/scripted setups):

  • MEMBERSHIPS_STRIPE_KEY — publishable key
  • MEMBERSHIPS_STRIPE_SECRET — secret key
  • MEMBERSHIPS_STRIPE_WEBHOOK_SECRET — for webhook signature verification

Dedicated env vars (not the generic Stripe ones) so an install can have other Stripe wiring elsewhere without collision. Resolution happens in MembershipsStripe::resolveCredential() — Setting first, env second.

Updates & License page

Settings → Updates & License shows the consuming side in two cards. Your Plan names the install's tier with its tagline, shows "N of 5 sites in use" on a capped plan, and offers View Plans & Upgrade except on Enterprise, a comp, or the license server itself. Membership Status lists whether the install is licensed (an unlicensed one can't save pages or take updates), whether it is an active member, the tier, when it last checked, the truncated license key with a (from dashboard) or (from .env) indicator, and the effective API URL. Check Now runs the same MembershipClient::check() as the six-hourly scheduled check and shows the response (active, tier, expiry) inline. The dashboard home's Your plan nudge, shown only to installs without an active plan, links here with Manage.

Admin pages

All under Dashboard → Memberships (visible to admins when the feature is on):

  • Members — paginated list with search (email / name / license key / last-seen host) and status filter. Each row shows last-seen timestamp + host so deauthed installs still phoning home are visible at a glance.
  • Plans — CRUD per plan, grouped by category. Stripe price ID + custom-amount fields.
  • Plan Categories — CRUD. The is_license_unlock flag on a category is what makes its plans count for the /api/membership/check active=true decision.
  • Settings — mode toggles + this install's license key + API URL (see above).

The New Member form lets admins manually create comp accounts without Stripe — no stripe_customer_id is set, so the Member can log in and use the dashboard but isn't billed. Useful for staff, comp customers, and end-to-end testing.

Accounts created on someone's behalf — this form, and the Courses Enroll a list import — go through MemberProvisioner (app/Features/Memberships/Support/MemberProvisioner.php): createManual() stores the email lowercased, falls back to the email's local part for the name, and captures the CRM contact when crm.capture_members is on; setPasswordUrl() mints the members-broker set-your-password link (expires after 60 minutes).

The Edit Member screen lets admins change status (active/paused/cancelled), edit admin notes, set a per-member Monthly AI credit and — on a comp — the Billing account (the member who pays for the site, whose line items its managed-AI credit then follows) and the AI allowance plan it falls back to (see managed-ai-credits.md); a billing member's page lists the sites it pays for, mark the member Tax exempt and review an exemption they claimed at sign-up (see Selling to a tax-exempt buyer), regenerate the license key, release one of their sites from the Sites card on the license server (see Releasing a site), log work in the Task log against a per-unit plan's paid allowance (see Per-unit plans → Usage), and see all line items (active + cancelled). For Stripe-backed members, line-item changes happen via the Stripe Dashboard — webhooks sync the changes back to the local record. For manually-created comp members, line items are read-only on the edit page.

Comping a customer (free subscription)

Three supported paths depending on whether the comp needs a specific plan/tier attached and whether you want it tracked in Stripe.

The Comp / Exempt switch (simplest — works on existing members)

For installs you host yourself or comp outright, where the member just needs to count as active on the license check and no specific paid tier matters.

  1. Dashboard → Memberships → Members → edit the member
  2. Toggle Comp / Exempt on, Save

While the member's status is Active, /api/membership/check reports them active=true with tier exempt (a real paid line item's tier still wins if one exists). No plan, no line items, no Stripe. The members list shows a Comp badge next to their status. To revoke, toggle it back off — setting the member's status to cancelled/paused also deactivates an exempt member.

Comp as a tier. Tier exempt unlocks every member feature but satisfies no requires_tier gate — so a plain comp can never enable the Agency-locked Fleet Management card or the developer tooling. When the comp needs a specific plan (an agency you host, your own agency site), pick it in the Comp as select that appears under the switch: the license check then reports that tier (business / agency / enterprise; member_profiles.exempt_tier) instead of exempt, and the badge reads e.g. Comp · Agency. Fleet Management and the developer tooling need agency or above. A paid line item on the same member still wins. Switching the comp off clears the pinned tier. A comp is never site-capped, whatever tier it is pinned to — the five-site limit applies to Core and Business licenses, never to a comp.

This is the only way to comp an existing member with no Stripe subscription: line items can only be attached at member creation (Path A) or through Stripe (Path B).

Path A — Local-only comp (no Stripe, simplest)

For staff, beta testers, friends, or anyone where billing tracking doesn't matter.

  1. Dashboard → Memberships → Members → New Member
  2. Enter email + name; the form auto-generates a 16-char password (copy it to share, or have them reset via the forgot-password flow)
  3. Status: active
  4. Under "Assign plans (optional)" pick the plan you're comping (typically Members in the Software Features category — that's the one that flips active=true on the license check)
  5. Make sure Generate license key is on
  6. Create Member

The member can immediately log in at /members/login, see their license key, and use the install normally. The "Manage billing" button doesn't appear on their dashboard because they have no Stripe customer — which they don't need.

To revoke: edit the member, set status to cancelled (or cancel the specific line item). No Stripe interaction needed.

Caveats: doesn't appear in Stripe reporting (it's purely local), and the member can't self-cancel because there's no Stripe subscription to cancel.

Path B — Stripe-tracked comp via 100% coupon

For comps you want visible in Stripe reporting — press, partners, key customers — where you want the "would be paying $X/mo" data point.

  1. Stripe Dashboard → Coupons → Create
    • Type: Percentage discount
    • Percent off: 100
    • Duration: Forever
    • Name something memorable like "Comp"
  2. Share the coupon code with the customer
  3. They sign up normally at /members/register, picking the plan they'd otherwise pay for
  4. At Stripe Checkout they paste the coupon code; their first invoice is $0 and a real subscription is created
  5. Webhook fires, our sync runs, they get a license key just like a paying customer

Or do it without involving the customer — create a Customer manually in Stripe Dashboard, attach a subscription with the coupon, and webhook will sync it into your local member list automatically.

Pros: real Stripe subscription, customer has Customer Portal access, comps show in Stripe revenue reports as discounted line items.

Cons: more moving parts; the customer has to complete a signup flow (or you do it for them in Stripe).

Which path to pick

  • Installs you host / permanent comps where the tier doesn't matter: the Comp / Exempt switch. One toggle, works on existing members.
  • A comp that must reach a tier-locked feature (Fleet Management, developer tooling): the Comp / Exempt switch plus Comp as → Agency.
  • Internal team / quick favors that need a real plan: Path A. Faster, no Stripe noise.
  • External comps you want to track: Path B. Better bookkeeping.

Don't use the "Custom Software" plan at $0 — it works but creates a one-off Stripe price object per comp, cluttering your Stripe account for no real benefit.

Selling to a tax-exempt buyer

A nonprofit or government buyer can buy any plan through the normal checkout without being charged sales tax. Either the buyer claims the exemption when they sign up, or you mark their account Tax exempt before they check out. Either way, Stripe Tax charges them no tax on anything billed to their Stripe customer. Every membership checkout and unit charge runs Stripe's automatic tax.

This replaces duplicate "(Tax Free)" Stripe products sold through Payment Links. A Payment Link purchase creates no CMS member, so the buyer gets no members area and the subscription isn't linked to an account. Every reprice also meant rebuilding the twin.

Claimed at sign-up

Every install whose sign-up page sells a paid plan offers this. There's no setting. The exemption is applied straight away and you review it afterwards.

  1. The buyer claims it. On /members/register they tick Our organization is tax-exempt (a nonprofit, school or government agency) and attach their exemption certificate: a PDF, or a JPG or PNG photo, up to 5 MB. Without a certificate the claim is refused and no account is created. If they untick the box, the file is dropped.
  2. You're emailed. The certificate is stored privately, and an email ("[Site] Dana Owner claimed a tax exemption") goes to the Fleet alert address (fleet.alert_email), or to every admin when that is empty. It has the member's name, email and business, and links to their admin page and to the certificate. The certificate itself is never attached. A mail failure is reported and doesn't stop the sign-up.
  3. It's applied before checkout opens. The member's Stripe customer is set to exempt first, and only then is the checkout session created, so the first invoice shows $0 tax. If Stripe refuses or can't be reached, no checkout opens: the buyer is signed in and sent to their Plans page (/members/upgrade) with a message, and choosing a plan there tries again, exemption first.
  4. Review it afterwards. The Members list shows a Review tax claim badge until you do. On the member's page, the Stripe card says when they claimed it and has Download certificate and Mark reviewed. Marking it reviewed only clears the badge. If the certificate doesn't hold up, switch Tax exempt off: later invoices are taxed, and invoices already issued stay untaxed. While the claim hasn't been applied yet (the page says so), the switch already shows off, so use Decline claim instead. No checkout applies it after that.

How the claim behaves:

  • It's applied once. Until it's applied, the claim is pending. Once it's applied, declined, or the member's status is changed by the switch or in the Stripe Dashboard, it's settled and never applied again. So switching it off sticks, even when the buyer checks out again later. Applying a claim takes the same per-member lock as the switch and Decline claim, so switching it off or declining it while the buyer is starting checkout can't be overridden. A checkout with no pending claim checks that with one query and never waits on the lock.
  • Every checkout that bills the member's customer applies it first: membership checkout (sign-up, /members/upgrade, /members/subscribe) and the shop's product-subscription checkout.
  • A sign-up that buys nothing yet keeps the claim pending. No Stripe call is made at sign-up, and it's applied before their first checkout.
  • Social sign-up can't carry a claim, because a provider sign-up leaves the page. With the box ticked, the page tells the buyer to sign up with their email instead. A buyer who already signed up that way is marked exempt with the switch below.
  • Where nothing is for sale the box isn't shown, and a claim posted anyway is ignored.
  • The certificate is on the private disk under members/tax-certificates/{member id}/. It downloads only for Admins and above (the role the Members screens are routed under), always as an attachment and never cached. Deleting the account deletes it, and every backup includes it (member-tax-certificates/).
  • The sign-up copy promises only that the subscription is billed without sales tax. The exemption covers everything in What it covers, but that's all the buyer is told.

Marking a member exempt yourself

For an existing member, or a buyer you'd rather set up yourself:

  1. Get the buyer's exemption certificate and keep it on file. Stripe doesn't collect it, and the switch doesn't ask for it.
  2. Make sure the account exists. An existing member needs nothing extra. To set up a new buyer yourself, create the account under Dashboard → Memberships → Members → New Member and send them the set-password link. (A new buyer can also claim it themselves at sign-up, above.)
  3. Switch Tax exempt on. It's in the Stripe card under Members → edit. The switch changes Stripe straight away and isn't part of Save. A member with no Stripe customer yet gets one created and stored on their profile, exactly as checkout would create it, and checkout then uses that customer.
  4. Send them to checkout. Use /members/subscribe?plan={slug} (see The subscribe entry URL) or /members/upgrade. They sign in and check out on the normal embedded checkout, and the tax line comes to $0.

To make the buyer taxable again, switch it off. That sets the customer back to none in Stripe.

What it covers

Stripe Tax reads the customer's status each time it computes tax, so the exemption applies to everything billed to that customer from then on:

  • a new subscription from checkout, whether at signup or on /members/upgrade;
  • plans added or swapped on /members/upgrade, and units charged now on a per-unit plan;
  • every renewal of a subscription that already exists. Exempting a current subscriber takes effect from their next invoice;
  • shop product subscriptions the same member buys, because the shop reuses the member's Stripe customer.

It doesn't change:

  • Invoices already finalized. They keep the tax they were issued with. Refund that tax in Stripe if the buyer was charged it.
  • AI credit top-ups. The top-up checkout doesn't run automatic tax for anyone, so there's no tax to exempt.
  • Client Billing invoices. That module bills through its own Stripe customer.

How the state is kept

  • member_profiles.tax_exempt mirrors the Stripe customer's tax_exempt (exempt or none). The edit screen and the Members list (a Tax exempt badge) read it without calling Stripe.

  • Only Stripe's answer is stored. If Stripe refuses the change or can't be reached, nothing changes locally and the error shows under the switch. The screen never shows "exempt" while Stripe would still charge tax.

  • Changes made directly in the Stripe Dashboard reach the column through the customer.updated webhook, which re-reads the customer because Stripe doesn't deliver events in order. A deleted customer (customer.deleted) clears the flag. Both only work if those events are enabled on the memberships endpoint (see Webhook events handled).

  • Reverse charge. Stripe's third status, reverse (EU reverse charge), shows here as not exempt. Flipping the switch on a reverse-charge customer replaces it with exempt, and switching off sets none.

  • One customer per member. Membership checkout, the shop's subscription checkout and the switch all get the customer from MemberStripeCustomer. It reuses a stored customer and holds a per-member lock while it creates one, so an admin flipping the switch while the buyer starts checkout can't leave them with two. Changes to the status, from the switch or the webhook, also run one at a time per member.

  • The stored customer is missing in Stripe. The member's customer id may not be on the account the site's keys reach: it was deleted, or the keys belong to another Stripe account or to test mode. The switch then says so and marks the member not exempt. It does not create a replacement customer. If test keys are sitting over a live customer id, the real customer still exists, and replacing the id would cut the member off from their subscription once the keys were fixed.

  • Who can use it: Admin and above, the role the Members screens are routed under. The action checks this itself as well as relying on the route. It also needs the site's Stripe keys (Settings → API Keys) and says so if they're missing.

  • Seat members are billed through their team owner, so the switch refuses to mark a seat exempt. Set it on the owner.

  • A sign-up claim lives on member_profiles too: tax_exempt_certificate_path, tax_exempt_claimed_at, tax_exempt_claim_settled_at (applied or decided; pending while empty) and tax_exempt_claim_reviewed_at. MemberTaxExemption::applyPendingClaim() re-reads the profile from the database under the member's lock before deciding, because the member's cached profile may predate a claim recorded moments earlier.

Code: MemberTaxExemption, TaxExemptionClaim, MemberStripeCustomer, MembershipsStripe::setCustomerTaxExempt(), MemberTaxCertificateController. Tests: MemberTaxExemptTest, MemberTaxExemptClaimTest.

Webhook events handled

The webhook controller at /members/stripe/webhook (CSRF-exempted, signature-verified per request) dispatches these Stripe events:

Event Effect
checkout.session.completed Run sync — create line items, generate license key if applicable. An AI-credit top-up is credited here only once its session is paid
checkout.session.async_payment_succeeded A bank payment (ACH) that cleared days after checkout: same handler as completed — a top-up is credited now (once, keyed on the session id) and a subscription syncs to Stripe's current state
checkout.session.async_payment_failed A bank payment that bounced. A top-up was never credited, so nothing is taken back — the failure is logged with the session and member ids. A membership's subscription needs nothing here: Stripe moves it to past due / incomplete and customer.subscription.updated pauses the member
customer.subscription.created Run sync
customer.subscription.updated Run sync — handles plan swaps, line-item adds/removes done in Stripe Dashboard, and mirrors quantity changes made via the billing portal or Stripe Dashboard onto the local line item
customer.subscription.deleted Mark member cancelled, soft-cancel all line items
invoice.payment_failed Mark member paused; advance the dunning stage, capture Stripe's next retry time, send the escalation email. Ignored for a one-off unit-purchase invoice (metadata member_unit_purchase) — the changer handled it synchronously
invoice.payment_succeeded Clear paused status if member was previously paused; reset the dunning episode. Ignored for unit-purchase invoices, which also earn no affiliate commission
customer.updated Only when tax_exempt changed, and differs from what's stored: re-read the customer from Stripe and mirror it onto member_profiles.tax_exempt (see Selling to a tax-exempt buyer). Optional, but without it a status changed in the Stripe Dashboard won't show here
customer.deleted Clear member_profiles.tax_exempt for the member who held that customer. Optional, like customer.updated

Bank payments (ACH). Stripe can make a subscription active while its first bank payment is still processing, and the sync follows Stripe: the member gets access during that window, and a debit that bounces moves the subscription to past due (the member is paused) and, after Stripe's retries, cancels it — the same path as a declined card. Subscribe the endpoint to both checkout.session.async_payment_* events so bank-paid AI top-ups are credited.

Signature verification failures return 400. A handler failure returns 500 so Stripe retries the event. The handlers sync to Stripe's current state, so a retry after a transient failure heals rather than doubling anything.

Extending memberships

The pattern is custom features that subscribe to membership state, not core modifications. The shape:

  1. A manifest.json declaring the feature
  2. A ServiceProvider that boots when the feature is enabled, registers a runtime filter (Features::registerEnableFilter()), and short-circuits when disabled
  3. Filter checks Membership::isMember() (or any other state) and returns a veto

(The core feature gate in MembershipsServiceProvider::registerMembershipFeatureGate() is the canonical example of such a filter — see the full developer guide in custom-features.md.)

Customers wanting their own pattern (e.g. "unlock an extra dashboard page when member") write a small custom feature, install it via the dashboard's Settings → Features → Custom tab using the ZIP upload, and toggle it on. The custom feature subscribes to whatever extension points it needs.

The core memberships module stays focused on the generic primitives (signup, billing, content gating, license keys); install-specific business rules live in install-specific code.

What it's not

  • Not a course platform. Members can be gated to specific pages and posts, but there's no built-in lesson tracking, quiz engine, or completion certificates. Combine with the Courses feature (when shipped) for that.
  • Not per-install metering for your own software. One member has one license key, and any number of installs can check in with it — a WebProCMS install is recorded per install (by its install_id), but only the WebProCMS license server caps how many installs a key may activate (see the site limit above). A key you issue for your own software has no install limit.
  • Not a tax/VAT engine. Stripe Tax computes the tax: every membership checkout and unit charge runs Stripe's automatic tax, and this module doesn't compute or display tax breakdowns itself. For exempt buyers, see Selling to a tax-exempt buyer.
  • Not a coupon engine. Stripe Checkout's allow_promotion_codes is enabled by default, so promo codes created in Stripe work. There's no in-CMS coupon builder.
  • Not legal advice. Cancellation timing, refund policies, and consumer-protection law vary by jurisdiction — admins are responsible for their own terms of service and consulting counsel when crossing borders.