Version 5.2.0
July 26, 2026
Contains security fixes — upgrade promptly. Also contains breaking changes to the REST API and to
POST /api/checkout; see the Security and Changed sections below.Access rules now stop front-end requests on their own. Read the Fixed section before upgrading a site that already has access rules configured.
Security
- The settings screen is now admin-only.
SettingsControllerhad no authorization check of any kind, so any user who could reach the control panel could read the Stripe and PayPal secret keys, the webhook signing secret, and the REST API key — or POST new ones. Viewing requires an admin; saving additionally requiresallowAdminChanges, matching Craft's own plugin settings screens /admin/headcount(the reporting dashboard) now requiresheadcount-viewReports. The permission was enforced on the reports and data actions but not on the dashboard action, so MRR, churn and revenue figures were readable by any control-panel user. The check moved tobeforeAction()so it covers the whole controller, as it does elsewhere in the plugin- REST API keys are no longer accepted in the query string.
?apiKey=leaked keys into web server access logs, browser history, and theRefererheader of outbound links. Pass the key in theX-Headcount-Api-Keyheader instead. The comparison is nowhash_equals(), so it no longer leaks the key through timing. Breaking for any integration using?apiKey= POST /actions/headcount/api/checkoutnow requires a CSRF token.ApiControllerturned CSRF validation off for every action, including the one that changes state, so any site could make a logged-in member's browser open a checkout session. CSRF stays off for the read-only, key-authenticated endpoints, which have no session and no token to send. Breaking for front-end JS that posts to/api/checkoutwithoutCRAFT_CSRF_TOKEN— the equivalent front-end action,headcount/checkout, already required oneGET /api/subscription?id=no longer skips its ownership check when there is no session. The check read "if there is a logged-in user and the subscription isn't theirs, refuse", which would have handed any member's subscription to a session-less caller
Fixed
- Access rules were never enforced. Craft resolves a front-end element URL and renders
its template without ever calling
canView(), so theauthorizeViewhook the rules relied on never fired — visiting a gated URL directly served the page in full. Rules are now applied inbeforeAction, whereredirect,paywallandhideactually mean something. If your templates were compensating for this, gated pages will start redirecting or 404ing; turn offenforceAccessRulesto keep the old behavior. - No access rule could be saved at all.
targetUidwas a NOT NULL column that nothing ever populated, and the save path wrote null into it, so every save failed on an integrity constraint. It's now nullable. - The rule editor saved the wrong target. It rendered one target field per scope and
only hid the inactive ones — and a hidden input still posts — so with every field named
targetId, whichever came last in the DOM won. Saving a "section" rule stored the empty category ID and the rule then matched nothing. Target fields are now keyed by scope. - Saving a rule whose scope its element type doesn't offer is now a validation error rather than a rule that silently never matches.
- API-key authentication actually works. It was documented but unreachable: Craft rejects
a session-less request before a controller's
beforeAction()runs unless the action is in$allowAnonymous, so the key check was never consulted — and every key-authenticated action then went straight togetUser()->getIdentity()and returned 401 anyway.subscriptions,subscriptionandmemberare now anonymous-allowed and gated by the key check itself, which fails closed: with no API key configured, anonymous callers get 401 - A failed API-key check returns
401instead of falling through to a404page. ReturningfalsefrombeforeAction()left Craft with no action result, which it treats as an unmatched route
Changed
- API-key requests must name the member they act for, via
?userId=or?email=on/api/subscriptions,/api/subscriptionand/api/member. Headcount has a single global API key, so it is a trusted server credential with no member of its own; a missing selector returns400and an unknown member returns404. Session requests are unchanged — they always act for the logged-in user and deliberately ignoreuserId/email, so a member still cannot read another member's billing data.POST /api/checkoutandGET /api/portalremain session-only - The member lifecycle emails are Craft system messages now. Welcome, receipt, payment
failed, expiration reminder, trial ending, cancellation and drip-unlocked were seven
hardcoded English strings in
services/Emails.php— no site could change a word of them, they couldn't be translated, and because they were sent as hand-builtnl2br(htmlspecialchars())plaintext, the site's HTML email template never reached them. They're now registered under Settings → Email → System Messages like every other email Craft sends, and they render through the same template as the rest of your site's mail. The default wording has changed slightly in the process; edit it to taste. Headcount::emailDefinitions()exposes the messages, the settings attribute that switches each one on, and the variables its body may use — so a host bundle can list them all on one screen, and so the copy can be checked against its own placeholders.SendMemberEmailcarries a message key and a scalar variable set instead of a pre-rendered subject and body. A queued job's payload is serialized to the database, so keeping elements out of it is worth doing for its own sake.AccessRule::$typeis a plain string rather than a fixed enum, because the set of scopes is open. Theentryscope is migrated toelement, which is the same thing generalised.Gating::getRulesForEntry()is deprecated in favour ofgetRulesForElement(). It still works.craft.headcount.canAccess()accepts any element rather than only anEntry.
Added
- Mount seam so Headcount can run as an internal module of a host bundle plugin
(
mountedUnderShowtime,bootFeatures()/bootChrome(), andsaveSettings()routing through an injected writer). Standalone behavior is unchanged craft.headcount.plugin— resolves the plugin instance in both standalone and mounted modes, unlikecraft.app.plugins.getPlugin('headcount')- Headcount now refuses to install on a site where a host bundle that already includes it is
installed. Both copies would register the Subscription element type and share the
headcount_*tables, and uninstalling either would then drop the other's data. Headcount::permissionDefinitions()exposes Headcount's permissions, so a host bundle can list them under a single combined heading instead of one heading per bundled plugin. The permission keys are unchanged, so existing user groups keep working. Installed standalone, Headcount registers its own "Headcount" heading exactly as before.$stripeWebhookRouter— when Headcount runs as a module of a host bundle that owns the Stripe account, the host can take over webhook handling so this endpoint and the bundle's verify and route identically. Sites that pointed Stripe at/actions/headcount/webhook/stripebefore bundling keep working, and behave the same as ones using the bundle's endpoint. Null (standalone) → Headcount handles it itself, unchanged.- Access rules are no longer entry-only. A rule names an element type as well as a
scope within it, so anything registered as gateable can be gated — a plugin's own element
type, or (in the Showtime bundle) Owl events. Two element-type-agnostic scopes,
allandelement, sit alongside the existing entry scopes. Gating::canAccess(ElementInterface, ?User)— the plain question, for any element.Gating::EVENT_REGISTER_GATE_TARGETSandGating::EVENT_MATCH_GATE_RULElet other code declare its own gateable element types and the scopes they support. A scope no handler claims matches nothing, so a stale rule can never end up gating everything.craft.headcount.gatingResultexposes the gate that paywalled the current page, so a template can render a teaser. (Thepaywallbehavior withholds nothing by itself — see the variable's docs.)- An
enforceAccessRulessetting, on by default, for sites whose templates already gate content themselves and don't want rules applied automatically. Members::forEmail()andMembers::subscriptionsForEmail()— memberships keyed on an email address rather than a Craft user ID. Headcount keys on users, but everything that wants to ask "is this person a member" (a booking, an order, a support enquiry) arrives holding an address. Active subscriptions sort first.
Version 5.1.0
July 19, 2026
Fixed
- Outgoing webhooks now actually dispatch. The documented events (
subscription.created,subscription.updated,subscription.canceled,subscription.expired,member.upgraded,member.downgraded) were never being sent; subscription lifecycle changes now deliver HMAC-SHA256 signed payloads to the configured endpoint via a queued job - Subscription element index columns (User, Plan, Status, Gateway, Amount, dates) now render again. The custom column renderer overrode Craft 4's
tableAttributeHtml(), which Craft 5 renamed toattributeHtml(), so it was never called and itsparent::call would fatal - Stripe
customer.subscription.updated/invoice.payment_succeededwebhooks no longer risk a fatalTypeErrorwhen Stripe omits a period-end timestamp; an unparseable date now falls back tonullinstead offalse - Content gating by plan no longer throws a fatal
TypeError. JSON columns (planIds,features,metadata) were encoded twice on write, and the code paths that read them via a raw query or element population decoded only once — leaving a string where an array was required. This crashed content gating (Gating) and drip release (Drip) for any rule with required plans, loading a plan withfeaturesset, and loading a subscription withmetadataset. These values now decode defensively; existing double-encoded data is read correctly
Developer
- Static analysis and code style are now wired up:
composer phpstan(PHPStan level 5, clean) andcomposer ecs/composer ecs-fix(craftcms/ecs) - The
unitPHPUnit suite runs again — the bootstrap now loads Yii2's globalYiiclass, which Composer's autoloader does not provide - A Codeception + Craft integration harness (
composer test-integration) boots a real Craft app against a throwaway database and covers the highest-risk paths: webhook idempotency, subscription lifecycle + user-group sync, gating, MRR reporting, coupon validation, and JSON-column decoding.composer testruns both suites. Seetests/README.md SubscriptionQueryis annotated with element-query generics sofind()->one()/->all()are typed asSubscription
Added
- Plan change detection: Stripe
customer.subscription.updatedevents now sync the subscription's plan and emitmember.upgraded/member.downgradedwebhooks based on the price delta Subscriptions::changePlan()service method for moving a subscription to a different plan
Version 5.0.0
May 5, 2026
Added
- Initial release for Craft CMS 5
- Stripe payment integration with Checkout Sessions and Customer Portal
- PayPal subscription integration via REST API v2
- Custom Subscription element type with full lifecycle management
- Membership plans with configurable billing intervals and trial periods
- Content gating by section, entry type, category, or individual entry
- Drip content scheduling with day-based delays
- Coupon and discount code system synced with Stripe
- User group synchronization for tiered access control
- Member self-service portal for subscription management
- Transactional email system (welcome, receipts, reminders)
- Reporting dashboard with MRR, churn, and growth metrics
- Dashboard widgets for membership overview and revenue
- REST API for headless/decoupled architectures
- Twig extension with
craft.headcounttemplate variable {% headcountGate %}Twig tag for inline content gating- Console commands for subscription sync and expiration processing
- Webhook processing for Stripe and PayPal events
- Full CP management interface with plans, subscriptions, access rules, drip schedules, and coupons