Blog memberships, paid posts and password posts
A blog can decide, post by post, who reads the whole of it: everybody, signed-in readers, paying members, or whoever knows a password. It can also sell those memberships — monthly, yearly, a founding tier, a group of seats, a gift — and take one-off tips, all billed on your organization's own Stripe account.
Nothing here is on until you turn it on. A blog that never touched it is free and public, exactly as every blog was.
Who can read a post
Every post has a Who can read it field (access), set in the post's details in the studio or on the create / PATCH …/blog/{topicId} body:
| Who reads the whole post | What everybody else gets |
|---|---|---|
| Everybody. | — |
| A signed-in reader of the blog (an account your reader sign-in knows). | The preview, then a card inviting them to sign in or subscribe. |
| A reader with a live paid membership of this blog (see Memberships). | The preview, then the plans. |
| A reader who has entered the post's password (see Password posts). | The preview, then a password box. |
Preview length (previewBlocks, 1–20, empty is 3) is how many top-level blocks — paragraphs, images, lists — the preview keeps. A post no longer than that is cut to one block less, so the gate always hides something: a one-block post shows no body at all. The title, the standfirst, the cover and the byline always stay; they are what the card is selling.
A share link (a preview token you made for a reviewer) opens a gated post too.
The cut is made on every door
The preview is cut by the API, not hidden by the page, because a page-side cut ships the whole body to anybody who reads the JSON. Every door that hands out a post's body applies it:
Door | A gated post gives |
|---|---|
The post page and | The whole post to a reader who passes; the preview and a |
The post's | The preview, always — the Markdown door is shared-cached and anonymous. |
Download all media ( | The cover and the media in the preview only. |
RSS, Atom and JSON feeds | The excerpt; never the body, whatever the feed's Feeds carry setting says. |
Search ( | The excerpt as the snippet, never a match from the body. |
oEmbed ( | The card it always is (title, summary, picture), sent |
The AMP copy ( | None: a gated post has no AMP copy and its AMP address is a 404. |
The podcast feed | Left out: a gated episode is not in the public feed. |
The newsletter issue | Title, excerpt and the button; the full body only goes out for a public post, even with Content: The whole post. |
An email-only post (delivery: "email") is a different thing: it has no web address at all, so every one of these doors answers 404 for it.
The card where the preview stops
theme.post.paywall words and shapes the card. Every key is optional; unset is the reader's own language default.
Key | Default | What it does |
|---|---|---|
| "This post is for members" (paid: "This post is for paid members"; password: "This post is password protected") | The card's heading. |
| "Sign in to read the rest of it." (paid: "Become a member to read the rest — and every paid post."; password: "Enter the password to read the rest.") | The line under it. Not drawn with |
| "Sign in to keep reading" | The sign-in button's words (members and paid posts). |
|
|
|
It sits in the server-rendered page at its final size, so nothing moves when the plans load. Like every post key, it can be set per tag (post.byTag) or on one post (customization.theme.post.paywall).
Memberships
Memberships are set in the studio under Paid memberships, or with PUT /orgs/{orgId}/spaces/{spaceId}/blog-membership (blog.publish). The body is a partial update; GET reads the whole set.
curl -sS -A 'my-app/1.0' -X PUT \
-H "Authorization: Bearer $FORUMULATE_TOKEN" -H 'content-type: application/json' \
https://forumulate.com/api/orgs/$ORG/spaces/$SPACE/blog-membership \
-d '{
"enabled": true,
"currency": "eur",
"monthlyCents": 600,
"yearlyCents": 6000,
"foundingCents": 15000,
"foundingName": "Founding member",
"trialDays": 7,
"name": "Supporter",
"benefits": ["Every paid post", "The monthly deep-dive", "Comment on everything"]
}'Key | Default | What it does |
|---|---|---|
|
| Sell memberships. Selling also needs a monthly or yearly price above 0. |
|
| One of |
|
| The monthly price in the currency's minor unit; |
|
| The yearly price; |
|
| A founding member plan: a yearly price above the yearly plan, for readers who want to give more. |
|
| What the founding plan is called. Empty is "Founding member". |
|
| Free days before the first charge (0–90), on every plan. |
|
| What the membership is called on the card and the checkout ("Supporter"). Empty is "<blog> membership" at checkout. |
|
| Up to 8 lines, listed on the card and the membership page. |
|
| Readers may type an offer code at checkout. An offer link works without it. |
|
| Sell gift memberships. |
|
| Sell group memberships (yearly seats). |
|
| A group's discount per seat, as a percentage of the yearly price (0–90). |
|
| Take one-off tips (see Tips). |
|
| Up to 5 amounts in minor units; empty is 3, 5 and 10 of the currency. |
Turning on enabled or tipsEnabled before the organization has connected Stripe is refused — 409, saying to connect Stripe first — rather than saved as a switch that does nothing.
The plans a reader sees
On a paid post's card and on the blog's membership page (/membership under the blog's address), a reader sees the name, the benefits and one button per plan that has a price: monthly, yearly and founding, with the trial noted when there is one. Subscribing needs a signed-in reader — a membership belongs to a person, and so does the paywall it opens — so a reader who is not signed in is offered sign-in first.
The button opens Stripe Checkout on your account. Coming back, the page confirms the session and the reader goes straight back to the post they were reading. A reader who already has a live membership is never sent to checkout again.
On the API, the reader's side is under /public/orgs/{orgId}/spaces/{spaceId}/blog-membership: GET (the offer, and the signed-in reader's own membership as mine), POST checkout ({"plan": "monthly" | "yearly" | "founding" | "group", "seats"?, "offer"?}, optional ?return=<post slug>), POST confirm ({"sessionId"}) and POST cancel.
What "live" means
A membership opens paid posts while it is active, trialing, past_due (a failed renewal Stripe is still retrying) or comped, and its paid-through date is in the future — judged by the database's clock, not a server's. Stripe's webhooks keep the status and the date current; a cancellation takes effect at the end of the paid period, so a reader who cancels keeps reading until then. canceled is not live.
Offer codes and offer links
An offer is a promotion code in your own Stripe account — you create the discount there, with whatever terms Stripe allows. Two ways in:
An offer link: the membership page with
?offer=CODE(/membership?offer=SPRING). The code is applied at checkout for that reader. A code Stripe does not know as a live promotion code is a422naming it.An offer box: with
allowPromotionCodes, the plans show an Offer code field and Stripe's checkout accepts a code typed there.
Group memberships
With groupsEnabled, the plans offer Buy for a group: one payer buys 2–500 yearly seats at the yearly price less groupDiscountPercent, and holds the first seat. The payer's membership page shows the group's code and how many seats are taken. Each colleague signs in and redeems the code on the membership page. A seat stays live only while the payer's membership is; when every seat is taken, the next redeem is refused (409).
Gifts
With giftsEnabled (and a monthly or yearly price), the membership page offers Give a membership: 1, 3, 6 or 12 months, paid once. Twelve months costs the yearly price when there is one; otherwise it is the monthly price times the months. The buyer may give the recipient's email — they are mailed the code and a link to the membership page with it filled in (?redeem=CODE) — or leave it empty and pass the code on themselves; the code is shown to the buyer once, after payment. The recipient signs in and redeems it; the gift runs that many months from the day it is redeemed. A code works once.
Codes are derived on the server and only their hashes are stored, so nobody reading the database is handed a working code. Redeeming is throttled, because a code is a credential.
Complimentary memberships
Give a free membership… in Paid memberships (or POST …/blog-membership/members with {"userId": "…"}) comps a reader: they read paid posts, are never billed, and keep it until you end it. End now (DELETE …/blog-membership/members/{membershipId}) ends any membership at once and, for a paid one, cancels the subscription in Stripe too.
GET …/blog-membership/members lists the last 500 memberships (live and canceled) with the reader's name and email.
Numbers
Revenue and growth in the panel, and GET …/blog-membership/stats:
mrr— monthly recurring revenue per currency, in minor units (a yearly plan counts as a twelfth);live— members who read paid posts right now, by plan;growth— the last 12 months: memberships started, memberships ended, and new newsletter subscribers;gifts— gifts sold and redeemed.
Two webhook events follow memberships: blog.membership.started and blog.membership.ended, each with the space, the reader, the plan, the amount and the currency. See Webhooks.
The "Bestseller" mark
theme.blog.bestseller — {"show": true, "threshold": 100} — puts a "Bestseller" mark beside the blog's name once it has at least that many paying members. Unset, it is never shown, and the count is never published.
The membership page and the portal
/membership is where an offer link, a gift email and a "manage my membership" visit land: the plans, the gift form, the redeem box and — for a member — their own membership, with its end date and, for a group payer, the group's code. theme.blog.portal shapes it and the signup form:
Key | Default | What it does |
|---|---|---|
| "Membership" | The membership page's heading. |
| shown |
|
| the theme's |
|
| off | The subscribe form asks for a name as well as an address. |
Password posts
Set access: "password" on the post, then its password:
curl -sS -A 'my-app/1.0' -X PUT \
-H "Authorization: Bearer $FORUMULATE_TOKEN" -H 'content-type: application/json' \
https://forumulate.com/api/orgs/$ORG/spaces/$SPACE/blog-post-password/$POST \
-d '{"password": "launch-day"}'4–200 characters;
nullremoves it. Setting it needsblog.publish;GETon the same path answers only{"set": true | false}— the password is never read back. In the studio it is in the post's settings.Only a salted scrypt hash is kept.
A reader who enters it gets a cookie for that one post, good for 30 days. The cookie is tied to the current password, so changing the password locks out everyone who had the old one — there is no list of sessions to revoke.
The reader's door is
POST /public/orgs/{orgId}/spaces/{spaceId}/blog-post-password/{topicId}/unlockwith{"password"}, limited to 10 tries a minute: it is a guessing door, and a post password is often short.
The doors in the table above treat a password post like any gated post: the feeds, search, .md and media zip carry only the preview, whoever is asking.
Tips and "Sponsor this blog"
A Sponsor this blog box at the foot of every post is a theme.post key:
Key | Default | What it does |
|---|---|---|
| off | Draw the box. |
| none | Your sponsor page (GitHub Sponsors, Patreon, Buy Me a Coffee), |
| the reader's "Sponsor" | The link's words. |
| "Enjoying this blog? Help keep it going." | The line above it (up to 300 characters). |
With tipsEnabled under Paid memberships, the same box also offers one-off tips in tipAmounts: a reader picks one and pays it through Stripe Checkout on your account — no account needed to give — and comes back to the post with a thank-you. Tips draw only where sponsor.show is on.
What you need to set up
Paid posts, memberships, gifts, groups, offers and tips need your organization's Stripe account connected under Settings → Payments, with payments switched on and Stripe's webhook pointed at the address that page shows, with
checkout.session.completed,customer.subscription.updatedandcustomer.subscription.deletedselected (memberships follow renewals and cancellations through them). How to do that is in Payments and the shop. Nothing is needed on Forumulate's side; there is no platform account in between.Until it is connected,
enabledandtipsEnabledcannot be switched on (409). A post you set topaidmeanwhile is still gated: readers see the preview and the card's heading and line, with no plans to buy.
Offer codes are promotion codes you create in your own Stripe account.
Members-only posts (
members) need nothing — only readers who can sign in. Which sign-in your readers use is in Reader access.Password posts need nothing beyond the password.
Gift emails go out through your organization's mail; with no address given, the buyer passes the code on themselves.
Newsletter referral rewards are gift codes too — see Blog spaces → Newsletter.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Public Blog Membership
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the blog membership in a space (public reader) |
| |
| Create the checkout in a space (public reader blog membership) |
| |
| Confirm the blog membership |
| |
| Create the checkout in a space (public reader blog membership gift) |
| |
| Confirm the gift |
| |
| Create the checkout in a space (public reader blog membership tip) |
| |
| Create the redeem in a space (public reader blog membership) |
| |
| Cancel the blog membership |
|
Public Blog Post Password
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Unlock a password-protected blog post |
|
Public Oembed
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Read a post’s oEmbed card |
|