Blog memberships, paid posts and password posts

Embed
Preview of the card
<a href="https://forumulate.com/guides/blog-memberships"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/caa7127c-ed06-46df-ba7a-d6bb2ab617b5/social-card.svg?v=3e7ae33eec8b" alt="Blog memberships, paid posts and password posts" width="600" height="315"></a>

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:

access

Who reads the whole post

What everybody else gets

public (unset)

Everybody.

—

members

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.

paid

A reader with a live paid membership of this blog (see Memberships).

The preview, then the plans.

password

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 GET …/blog/t/{slug}

The whole post to a reader who passes; the preview and a gate object (access, previewBlocks) to everyone else.

The post's .md

The preview, always — the Markdown door is shared-cached and anonymous.

Download all media (…/blog/t/{slug}/media.zip)

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 (…/blog/search)

The excerpt as the snippet, never a match from the body.

oEmbed (GET /public/orgs/{orgId}/spaces/{spaceId}/oembed?slug=…)

The card it always is (title, summary, picture), sent private, no-store so a cache never holds it.

The AMP copy (…/amp/<slug>)

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

heading

"This post is for members" (paid: "This post is for paid members"; password: "This post is password protected")

The card's heading.

body

"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 style: "minimal".

button

"Sign in to keep reading"

The sign-in button's words (members and paid posts).

style

card

card (a framed box on the reading measure), band (a full-width tinted band) or minimal (one ruled line, no body).

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

enabled

false

Sell memberships. Selling also needs a monthly or yearly price above 0.

currency

usd

One of usd, eur, gbp, cad, aud, nzd, chf, sek, nok, dkk, pln, jpy, inr, brl, mxn, sgd.

monthlyCents

0

The monthly price in the currency's minor unit; 0 is no monthly plan.

yearlyCents

0

The yearly price; 0 is no yearly plan.

foundingCents

0

A founding member plan: a yearly price above the yearly plan, for readers who want to give more. 0 is none.

foundingName

""

What the founding plan is called. Empty is "Founding member".

trialDays

0

Free days before the first charge (0–90), on every plan.

name

""

What the membership is called on the card and the checkout ("Supporter"). Empty is "<blog> membership" at checkout.

benefits

[]

Up to 8 lines, listed on the card and the membership page.

allowPromotionCodes

false

Readers may type an offer code at checkout. An offer link works without it.

giftsEnabled

false

Sell gift memberships.

groupsEnabled

false

Sell group memberships (yearly seats).

groupDiscountPercent

0

A group's discount per seat, as a percentage of the yearly price (0–90).

tipsEnabled

false

Take one-off tips (see Tips).

tipAmounts

[]

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.

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 a 422 naming 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

heading

"Membership"

The membership page's heading.

showBenefits

shown

false hides the benefits list on the card and the page.

buttonLook

the theme's

solid, outline or pill for the plans' buttons.

nameField

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; null removes it. Setting it needs blog.publish; GET on 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}/unlock with {"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

sponsor.show

off

Draw the box.

sponsor.url

none

Your sponsor page (GitHub Sponsors, Patreon, Buy Me a Coffee), https only.

sponsor.label

the reader's "Sponsor"

The link's words.

sponsor.body

"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.updated and customer.subscription.deleted selected (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, enabled and tipsEnabled cannot be switched on (409). A post you set to paid meanwhile 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

PublicBlogMembership_offer

GET /public/orgs/{orgId}/spaces/{spaceId}/blog-membership

Get the blog membership in a space (public reader)

client.blog.publicMembership.offer()

PublicBlogMembership_checkout

POST /public/orgs/{orgId}/spaces/{spaceId}/blog-membership/checkout

Create the checkout in a space (public reader blog membership)

client.blog.publicMembership.checkout()

PublicBlogMembership_confirm

POST /public/orgs/{orgId}/spaces/{spaceId}/blog-membership/confirm

Confirm the blog membership

client.blog.publicMembership.confirm()

PublicBlogMembership_giftCheckout

POST /public/orgs/{orgId}/spaces/{spaceId}/blog-membership/gift/checkout

Create the checkout in a space (public reader blog membership gift)

client.blog.publicMembership.giftCheckout()

PublicBlogMembership_giftConfirm

POST /public/orgs/{orgId}/spaces/{spaceId}/blog-membership/gift/confirm

Confirm the gift

client.blog.publicMembership.giftConfirm()

PublicBlogMembership_tip

POST /public/orgs/{orgId}/spaces/{spaceId}/blog-membership/tip/checkout

Create the checkout in a space (public reader blog membership tip)

client.blog.publicMembership.tip()

PublicBlogMembership_redeem

POST /public/orgs/{orgId}/spaces/{spaceId}/blog-membership/redeem

Create the redeem in a space (public reader blog membership)

client.blog.publicMembership.redeem()

PublicBlogMembership_cancel

POST /public/orgs/{orgId}/spaces/{spaceId}/blog-membership/cancel

Cancel the blog membership

client.blog.publicMembership.cancel()

Public Blog Post Password

Operation

Request

What it does

Shortcuts

PublicBlogPostPassword_unlock

POST /public/orgs/{orgId}/spaces/{spaceId}/blog-post-password/{topicId}/unlock

Unlock a password-protected blog post

client.blog.publicPostPassword.unlock()

Public Oembed

Operation

Request

What it does

Shortcuts

PublicOembed_oembed

GET /public/orgs/{orgId}/spaces/{spaceId}/oembed

Read a post’s oEmbed card

client.public.oembed.oembed()

The Forumulate teamUpdated