Paid memberships
Sell reader access to gated content. This is the second phase of the "publishing → newsletter → membership" work (issue #337); the first phase is Newsletter.
Status. #337 Phase 2 is complete: provider credentials, the tiers on sale, the membership lifecycle (webhook receiver, automatic audience grants, audit trail), the member-facing checkout,
/accountand join pages, the paywall, and member-only newsletters.
The idea
A paid member is not a new kind of account. It is an ordinary self-registered
user whose active subscription grants them one of the gated audiences —
the consumer read axis that already exists (KilnCMS.CMS.Audiences, see
Granular RBAC for how it differs from editorial roles).
- Content carries one
audience.:publicis world-readable; anything else is gated. - A reader carries a set of
audiences, and may read a published record whose audience is:publicor is in their set. - Self-registration lands a user as
:viewerwith no audiences — i.e. exactly a not-yet-paying member.
So a membership tier is a product that grants an audience, and billing is the loop that grants and revokes it. Nothing about the access model changes.
Two scopes, deliberately
| Scope | Who may change it | |
|---|---|---|
KilnCMS.Billing.Settings — provider credentials | Instance-wide singleton | Platform admin (global User.role) |
KilnCMS.Billing.MembershipTier — the tiers on sale | Per organization | Org admin of that site |
One payment-provider account serves the whole instance; each site sells its own
tiers. That asymmetry is load-bearing, and mirrors
KilnCMS.Mail.Settings (instance-wide DKIM) versus KilnCMS.CMS.SiteBranding
(per-site tokens). A tenant-less resource must not gate on an org-admin
check: it would resolve every actor against the default org and let one site's
admin rewrite payment credentials for every other site.
Known limit. Because credentials are instance-wide, a multi-org instance bills every site into the same provider account. The
KilnCMS.Keysseam keeps per-org credentials (e.g. Stripe Connect) possible later without a data migration.Access is not shared, though: entitlements resolve per organization, so a membership bought on one site never unlocks another's gated content — see Reading gated content.
Setting up
Everything lives at /editor/billing (platform admin only). Until both
secrets resolve, KilnCMS.Billing.configured?/0 is false and no payment
surface exists at all — no tier is offered and no provider call is made. A
fresh install is inert.
1. Credentials
Two secrets, configured independently because they rotate on different cadences:
| Secret | What it is |
|---|---|
| API secret key | The account-wide bearer credential used to open checkout and billing-portal sessions. Starts with sk_ or rk_. |
| Webhook signing secret | Verifies inbound webhooks. Copy it from the webhook endpoint you create in the provider's dashboard. Starts with whsec_. |
There is deliberately no publishable-key field: hosted checkout means Kiln redirects to a provider-hosted page and never mounts the provider's JavaScript, so the publishable key has no use here.
Each secret follows the key-provider model (KilnCMS.Keys),
the same one the DKIM key uses:
- Environment variable — recommended for production. You choose the variable name; there is no default, and a blank pointer is refused rather than silently falling back.
- File — recommended for production; the natural fit for Docker/Kubernetes mounted secrets. Trailing newlines are trimmed.
- Database (encrypted) — the zero-ops default: paste the secret and it is
stored AES-256-GCM encrypted. The encryption key is derived from
SECRET_KEY_BASE, so rotating that secret orphans the stored value unless you follow the rotation procedure, which re-encrypts it under the new secret (mix kiln.vault.reencrypt). Otherwise you will need to paste it again.
"Test connection" performs a live credential check and records which account the keys belong to, so a mistyped key fails at setup rather than at a member's first checkout. "Disconnect" clears both secrets and returns the instance to inert.
2. Tiers
Create one tier per thing you sell. Configure the price in your provider's
dashboard and paste its price ID into the tier, so there is exactly one source
of truth for money — Kiln stores a pointer, never an amount. price_config holds
display copy for the join page and is never used to charge.
Each tier grants exactly one gated audience. Several tiers may grant the same audience (a monthly and an annual plan, say); a reader keeps the audience while any of their memberships is active.
A tier's audience cannot be changed after creation. The entitlement logic
derives "which audiences billing owns" from this table, so changing an audience
would stop the old one being owned while live grants for it remained — stranding
an entitlement nothing would ever revoke. Retire the tier (active: false) and
create a new one instead, which also keeps the audit trail honest.
Retiring a tier stops new sign-ups; it does not revoke existing members.
3. Gating content
Set a post or page's audience to a gated value in the content editor. The audience selector only appears when more than one audience is configured.
Configuring audiences
The audience list is compile-time:
# config/config.exs
config :kiln_cms, :audiences, [:public, :member]Adding a paid audience therefore needs a config change, a recompile, and
mix ash.codegen + mix ash.migrate — the tier table carries a database CHECK
constraint mirroring the list, so that the public join page and paywall can never
be taken down by an out-of-band bad write.
Removing an audience while tiers still reference it leaves those tiers
unreadable; /editor/billing warns rather than crashing, but restore the
audience or retire the tiers.
The provider seam
KilnCMS.Billing.Provider is a behaviour with five callbacks — open a checkout
session, open a billing-portal session, retrieve a subscription, retrieve a
checkout session, and verify a webhook signature. One implementation ships,
KilnCMS.Billing.Providers.Stripe, selected via:
config :kiln_cms, KilnCMS.Billing, provider: KilnCMS.Billing.Providers.StripeIt is a thin Req client rather than a third-party dependency. The surface is
five endpoints against a stable API; a client library would add a permanent
advisory surface to the dependency-audit gate and could not be stubbed with
Req.Test, which is how every other outbound integration here is tested. The
provider's API version is pinned so a provider-side default bump cannot silently
reshape the subscription object the entitlement logic reads.
Deliberately not on the behaviour: creating customers (hosted checkout does it), creating prices or products (an operator does that in the dashboard), and cancelling subscriptions (the hosted portal owns cancellation — a local cancel primitive would create a second, divergent cancellation path).
Webhook signature verification
Inbound webhooks are authorized by an HMAC-SHA256 signature over the raw request bytes, not by a session — the caller is the payment provider, not a browser. Verification happens before JSON decoding, so unauthenticated bytes never reach the decoder, and it enforces a ±300s timestamp tolerance to bound replay of a captured request. Multiple signatures per request are accepted so a signing-secret rotation does not drop events.
How a membership grants access
The entitlement rule
Audiences are recomputed from scratch on every membership transition, never incrementally:
granted = audiences of every entitling membership (active | past_due | comped)
managed = audiences claimed by ANY tier, in ANY org, active or retired
preserved = current -- managed # admin-owned, untouched
new = preserved ++ grantedRecomputing rather than adding and removing is what makes replayed or out-of-order webhooks safe: the result is a pure function of current state, so applying it twice changes nothing.
Division of authority. For any audience some tier claims, billing is the sole
authority. Audiences no tier claims stay exactly as an admin left them. The
consequence worth knowing: once an audience has ever been claimed by a tier,
granting it by hand is transient — the next recompute drops it. Comping is the
supported lever (a membership with status comped, no provider subscription);
manage_access is the wrong tool for a tier-managed audience.
Retiring a tier does not un-manage its audience, deliberately. If it did, retiring a tier would freeze every existing grant of that audience permanently, with nothing left to revoke it.
Statuses
| Status | Grants? | Meaning |
|---|---|---|
incomplete | no | created before checkout, so the provider session can carry a stable id |
active | yes | paid and current |
past_due | yes | a payment failed and the provider is retrying. Access survives dunning — locking a member out for an expiring card would be wrong, and the provider keeps the subscription alive through its retry schedule |
canceled | no | terminal |
comped | yes | granted by an admin, no provider subscription |
Revocation timing
There is no local timer. The audience follows the mapped status and the status
follows the provider, which gives the right behaviour for free: with
"cancel at period end", the provider keeps reporting active for the whole paid
period and sends the deletion event when it lapses. Immediate cancellation
revokes immediately for the same reason. current_period_end is stored only so
the member UI can say "renews on…".
Webhook setup
Point a webhook endpoint at POST /billing/webhooks/stripe and enable exactly
four events:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failed
Anything else is acknowledged and ignored. Paste the endpoint's signing secret
into /editor/billing.
Processing is asynchronous: the receiver verifies the signature, records the
event, and enqueues it on the billing queue. The provider times out in about 20
seconds and treats a timeout as failure, so handling inline would be a latency bet
against an external API on a path whose failure mode is "the provider disables
your endpoint".
Diagnosing "the member paid but has no access" starts with the recorded events: each one carries its status and, on failure, the reason. Every entitlement change also appears in the governance trail with the audience delta and the provider event that caused it.
Organizations
The webhook's organization is resolved from the event, never from the request host — a webhook arrives at whatever host the provider was configured with, and trusting that would write memberships into the wrong site. Resolution goes: event metadata (verified against the stored membership), then the subscription id, then the customer id disambiguated by price. Nothing resolvable is acknowledged and ignored rather than guessed.
Because credentials are instance-wide, checkout stamps the organization onto both the checkout session and the subscription it creates: session metadata does not propagate to the subscription, and the subscription is what carries every later event.
Webhook events
A site can tell another system when a membership starts or stops granting
access: subscribe a webhook endpoint to
membership.activated and membership.canceled at /editor/webhooks. That is
enough to provision a hosted account for a paying customer, add them to a CRM,
or revoke either, without that system talking to the payment provider.
The events follow access, not every status change:
| Transition | Event |
|---|---|
incomplete or canceled → active, or any → comped | membership.activated |
active, past_due or comped → canceled | membership.canceled |
active → active (a renewal), active ↔ past_due (dunning) | none |
incomplete → canceled (an abandoned checkout) | none |
past_due still grants access, so a failing card is not a cancellation; the
provider giving up on it is.
// POST to your endpoint, membership.activated
{
"event": "membership.activated",
"data": {
"event_id": "…", // the audit row for this transition — dedupe on it
"membership_id": "…", "org_id": "…",
"user_id": "…", "email": "[email protected]",
"tier": { "id": "…", "slug": "hosted-starter", "name": "Hosted Starter", "audience": "members" },
"status": "active", "previous_status": "incomplete",
"occurred_at": "2026-09-18T12:00:00Z",
"activated_at": "2026-09-18T12:00:00Z", "canceled_at": null,
"current_period_end": "2026-10-18T12:00:00Z"
}
}The event cannot be lost to a crash. It is enqueued inside the same
transaction that grants or revokes the access, and delivered after that
commits, with retries. Content events make the opposite trade (see
KilnCMS.Billing.MembershipWebhooks for why). A redelivery carries the same
event_id, so a receiver that acts on it (creating an account, say) should
ignore an event_id it has already handled.
The payload carries the member's email. It is read when the event is
delivered, never stored in the job queue, but the webhook delivery log keeps
each payload for its retention window (30 days by default) so it can be
redelivered. GDPR erasure does not rewrite that log, and erasing a member
sends no membership.canceled: erasure ends the membership locally without
going through a transition.
The member journey
| Page | Who | What |
|---|---|---|
/membership | anyone | The tiers on sale. Where the paywall CTA points. |
/account | any signed-in user | Current membership, status, renewal date, "Manage billing", data export. |
A reader who isn't signed in is sent to register with their chosen tier
remembered, so the intent survives account creation — registration is the
identity step and shouldn't be entangled with payment. After signing in, readers
land on /account rather than the site root.
Both money-handling controls are ordinary form posts to
KilnCMSWeb.BillingController, not LiveView events: each ends on a
provider-hosted page on another origin, which a LiveView cannot navigate to. They
work with JavaScript disabled and survive a dropped socket.
Return URLs are built from the request's host, so a member on a custom-domain site returns to that domain — otherwise their session cookie wouldn't be there.
Coming back from checkout
The member may land on /account before the provider's webhook arrives. When the
membership is still incomplete, the checkout session is retrieved server-side and
applied through the same path the webhook uses — but only after verifying the
session's metadata names that user. The session id comes from the query string
and is therefore attacker-suppliable; nothing is ever granted from the query
parameters themselves. The webhook remains the durable path; this only removes
the "activating…" wait.
Reading gated content
The audience a reader holds is resolved per organization, from their
OrgMembership for the site being served:
| Reader | Result |
|---|---|
| member of this org | that membership's audiences |
| member elsewhere, none here | [] — fail-closed |
| no memberships at all | [] |
| anonymous | [], with no database lookup |
Fail-closed matters because the organization is resolved from a client-controlled host. Falling back to the global column for a foreign org would let a member of one site read another's gated content by switching hosts — the same reasoning the editorial scope axes already use.
The removed User.audiences fallback
Until 1.0, an account with no memberships at all read the global
User.audiences column on every org. 0.12 deprecated that fallback (#1538) and
1.0 removed it (#1543). An account still relying on it is moved onto a
membership on the default organization carrying its audiences and standing
role (and any live temporary role, with its expiry) — what the fallback granted
there — by KilnCMS.Accounts.LegacyAudiencesWorker, a background job queued on
every boot. Until the job has run, such an account reads only public content:
fail-closed, never wider. It is the same step the first paid membership and the
console's audience checkboxes take (below), and mix kiln.deprecations --migrate-audiences runs it by hand; run that on 0.12 before upgrading and
there is no gap at all. mix kiln.deprecations lists any account left.
The column itself is kept. No access decision reads it, but it is the only record of what a legacy account held, and billing still writes the cross-organization union there. 2.0 may drop it.
The first paid membership
The recompute gives a buyer a :viewer membership on each org they have paid on
and hold none for. A reader is not an author, so the paid membership never
raises a tier. It must not lower one either, and for an account with no
memberships at all it would: that account's first membership makes it
affiliated, which ends its standing-role tier on the default org. A legacy editor
buying on the default org would become a :viewer there; buying on another org
would leave it with no tier on the default org.
So before an account's first membership, the recompute gives it a default-org
membership carrying its standing User.role, any live temporary role with its
expiry, and its User.audiences. That is what it held on the default org before
it had a membership, so nothing it holds there changes, apart from the billing rule
above: a tier-claimed audience on that membership still lasts only as long as
something entitles it. The paid :viewer membership follows. The console's
audience checkboxes take the same step (below); both call
KilnCMS.Accounts.LegacyAffiliation. Each membership write is an upsert that
leaves an existing row alone, so two recomputes racing for one account write one
row per org (#1649).
Editing audiences from the console
The audience checkboxes on /editor/accounts/:id edit the account's
OrgMembership for the site the page is served from, which is the value the
table above reads. They never write the global User.audiences column. Before
0.12 they wrote only that column, so for any account holding a membership they
saved and changed nothing (#1646).
An account with no membership on that site gets one on the first save
(KilnCMS.Accounts.SiteAudiences), with the tier it already holds there, so
gaining a membership changes what it reads and nothing else:
| Account | Membership created |
|---|---|
| no memberships at all, edited on the default org | its standing User.role, plus any live temporary role with its expiry |
| no memberships at all, edited on another org | first a default-org membership carrying its standing role, any live temporary role and User.audiences (the same step billing takes, above), then a :viewer membership here |
| member of other orgs, none here | a :viewer membership here |
The extra default-org write in the middle row is there because an account's first membership anywhere makes it affiliated, and an affiliated account holds nothing on an org it is not a member of. Without it, a legacy editor given audiences on a second site would lose its editor tier on the default one. What still changes, on purpose: once the account holds a membership, the global column stops granting it anything on the sites it is not a member of.
Only platform admins may make these writes: they go through OrgMembership's
own actions and policies, which admit platform admins only, as /editor/team
does. The billing rule above still applies to the membership: a tick on an
audience some tier has claimed lasts until the next recompute.
The paywall
A gated document no longer 404s for a reader who isn't entitled to it. Instead the public site serves a teaser: the title, a summary, and a link to the plans.
The summary is excerpt || seo_description and nothing more. It is never
synthesised from the document body — doing so would mean fetching the block tree
for a reader who may not read it, and the whole safety argument rests on never
doing that. Note excerpt is opt-in per content type (present on posts and
entries, absent on pages), so a gated page teases on its title and SEO
description alone.
How the block tree is kept out
Three layers, only the third of which is a guarantee:
- The teaser read requires a non-public audience, so it can never stand in for the entitled lookup.
- Its
selectomitsblocks, so the column is never fetched. - The record is projected onto
KilnCMSWeb.Teaser— a struct with noblocksfield — before it reaches the template. Even if someone later widened the select, the template could not render a block.
Caching
Gated URLs are private, no-store with Vary: Cookie, for both the teaser
and the entitled member's render. The public delivery headers are
public, max-age=60, so without this a CDN would happily serve one member's
gated page to every anonymous visitor.
The in-process payload cache is the anonymous, public-only shape. Readers with audiences bypass it entirely rather than the key gaining an audience component: the key is a string, so correctness would depend on canonically serialising a set, and any slip there serves gated content to the wrong people. Bypassing makes that class of bug unreachable, at the cost of one query per gated page view.
Search engines
Both the teaser and the entitled render emit isAccessibleForFree: false and a
hasPart marking the gated region. They must agree: a crawler and a subscriber
being told different things about the same URL is the cloaking signal search
engines penalise. The canonical URL stays the document's own — never the join
page.
Gated posts appear on the blog index with a "Members" badge. They always did appear (the index filters on publish state alone); before the paywall their links simply 404'd.
What stays strict
The headless surfaces are unchanged. The JSON and GraphQL APIs never serve a
teaser, and the delivery audiences argument is hidden from the GraphQL schema
— otherwise an anonymous client could request gated content directly. Gated
content over headless still requires a bearer token or API key on an entitled
account. Sitemaps, llms.txt, feeds, related content, SEO link suggestions and
the optional Meilisearch index (#1006) all remain public-only — gating a document
removes it from the index, not merely from future updates.
Member-only newsletters
Each tier gets an auto-maintained newsletter segment whose membership tracks active paid memberships. It is the only kind of segment that may receive gated content: a hand-built segment is refused even if an admin labels it with the same audience, because that label has never been an access boundary.
Consent and entitlement stay separate bits — the sync writes only list membership,
never Subscriber.status — so a member can unsubscribe from email while keeping
full content access, and cancelling a subscription doesn't withdraw their consent
record.
A new member lands pending, not subscribed: paying for access is not consent to marketing email. Only a confirmed address is ever added to a list. See Newsletter for the details and for how to change that default.
Security notes
- Card data never touches Kiln. Both money-handling calls return a provider-hosted URL that the member is redirected to.
- Secrets are never returned by any API read: the encrypted columns are
sensitive?and not writable from input, and the provider-config column holds only a pointer (a variable name or a path), never key material. - Payment credentials are purged by the staging scrub, so a production clone cannot act on a real provider account.
See also
- Newsletter — subscribers, segments and sending
- Granular RBAC — how audiences differ from editorial roles
- Data flows — what personal data is stored, and subprocessors
- Extending content — the delivery reads the paywall widens