Skip to main content

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, /account and 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. :public is world-readable; anything else is gated.
  • A reader carries a set of audiences, and may read a published record whose audience is :public or is in their set.
  • Self-registration lands a user as :viewer with 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

ScopeWho may change it
KilnCMS.Billing.Settings — provider credentialsInstance-wide singletonPlatform admin (global User.role)
KilnCMS.Billing.MembershipTier — the tiers on salePer organizationOrg 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.Keys seam 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:

SecretWhat it is
API secret keyThe account-wide bearer credential used to open checkout and billing-portal sessions. Starts with sk_ or rk_.
Webhook signing secretVerifies 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.Stripe

It 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 ++ granted

Recomputing 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

StatusGrants?Meaning
incompletenocreated before checkout, so the provider session can carry a stable id
activeyespaid and current
past_dueyesa 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
cancelednoterminal
compedyesgranted 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.completed
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.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:

TransitionEvent
incomplete or canceled → active, or any → compedmembership.activated
active, past_due or comped → canceledmembership.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

PageWhoWhat
/membershipanyoneThe tiers on sale. Where the paywall CTA points.
/accountany signed-in userCurrent 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:

ReaderResult
member of this orgthat 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:

AccountMembership created
no memberships at all, edited on the default orgits standing User.role, plus any live temporary role with its expiry
no memberships at all, edited on another orgfirst 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 herea :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:

  1. The teaser read requires a non-public audience, so it can never stand in for the entitled lookup.
  2. Its select omits blocks, so the column is never fetched.
  3. The record is projected onto KilnCMSWeb.Teaser — a struct with no blocks field — 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