Blog spaces
A Blog space is dated, authored articles — a company blog, release notes with prose, an engineering journal. Its public prefix is /b/, and on a custom domain its posts are addressed from the root.
Everything below is the API. The studio writes the same fields through the same doors.
Write a post
curl -sS -A 'my-app/1.0' -X POST \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
https://forumulate.com/api/orgs/$ORG/spaces/$SPACE/blog \
-d '{
"title": "Rebuilding search in a week",
"slug": "rebuilding-search",
"publishedAt": "2026-05-25T09:00:00.000Z",
"markdown": "We rebuilt search and made it four times faster.",
"socialImageUrl": "https://cdn.example.com/og/search.png"
}'slugis the address. Ask for one and you get it or a409— never a silent-2suffix, because an import told to keep/blog/welcomehas not kept it if it lands on/blog/welcome-2. Renaming a post leaves the old address answering a308to the new one at once (both addresses are dropped from every cache on the rename);GET …/blog/{id}/slug/historylists what it used to answer to.publishedAtis the post's real date, which is what makes a migration possible.markdownis the body, andcontent(block JSON) is the same body in the other spelling. They are alternatives: send one. Sending both is a422namingmarkdown— nothing silently wins, because a client that sent the wrong field would otherwise see a201and the wrong body.sectionswrites named slots — see required sections.socialImageUrlis the link-preview image. It is accepted both at the top level, as shown above, and asseo.socialImageUrl; both spellings reach the same place, the post's declaredfields, so the studio,/itemsand this door all read one value. (It used to be accepted at the top level and silently dropped — Crossly C-19.)series,tags,excerpt,coverUrl,coverAlt(the cover's alt text, also itsog:image:alt),furtherReadingandcommentsEnabledare all settable here and onPATCH …/blog/{id}. An empty string clearsseries,coverAltorsocialImageUrl; omitting a key never clears anything.
PATCH takes the same body, plus editReason, which rides onto the revision.
Required sections, and what publishing refuses
A space's shape says what a piece of writing here must contain. The Blog type ships two sections, both required: Standfirst (the line that appears in listings and link previews) and The post.
POST …/blog/{id}/publish refuses a post that has those sections with a required one left empty — 422, naming the slot, because the fix is one sentence of writing and the message should say which one. /items/{id}/publish has refused the same thing for longer; the two doors now agree (Crossly C-20).
One thing it does not refuse, and why:
A post written as prose, with no section blocks at all — which is how markdown and most imports arrive. It has no slots to be missing, and refusing it would make every post written before its space grew a shape unpublishable. It still comes back in the publish response's
warningsarray, so the answer never pretends the post is finished.
A post with no body at all is refused, and this page said the opposite until the rule moved. An empty post is not the prose carve-out above: having a body is the whole of the contract for prose, and a post with no body meets neither half. It is judged as what it is — every declared slot, empty — by the same 422, so the outline the studio opens for an untouched post and the same emptiness sent over HTTP now get the same answer.
Comments
Comments are replies to a published post, written by readers at /b/… (or on your own domain). They are off until you say otherwise, and there are three separate switches.
Per post
curl -sS -A 'my-app/1.0' -X POST -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
https://forumulate.com/api/orgs/$ORG/spaces/$SPACE/blog/$POST/comments \
-d '{"enabled": false}'Closing a post hides its existing comments and refuses new ones with a 403. (It used to do only the first.)
Per space
PATCH /orgs/{orgId}/spaces/{spaceId}/blog/settings carries the space's community settings. GET the same path to read them.
Field | Default | What it decides |
|---|---|---|
| off | Whether a reader with no account may comment at all. Opt-in — set it explicitly, either way. |
| on | Whether a comment from somebody with no account waits for review. |
| off | Whether a comment from a member waits for review. Trusted members are exempt. |
|
| Who may start a piece here. |
| on | Whether readers may react to posts. |
|
| The order the index runs in. |
| — | The name new posts are published under when the writer gives none. Stamped at write time, so changing it never rewrites a published post. |
|
| The thread's order: |
|
| Close a post's comments this many days after it was published (0–3650; 0 never closes). The thread stays readable; the form goes and the comment door answers |
|
| How deep replies nest, 1–6. 1 keeps the thread flat; a reply at the limit sits beside its parent. |
|
| Split long threads into pages of 10, 20, 50 or 100 (any 0–200 over the API); 0 is all on one page. |
|
| Who hears about a new comment: the post's author always does; |
|
| The format a new post starts in (see Formats). Stamped when the post is written. |
|
| Up to 10 tags a new post starts with when its writer gives none. |
Two of those deserve spelling out, because they were the spam inlet a customer reported (Crossly C-21 / F-32):
allowAnonymousdefaults to off for comments. A space nobody has configured refuses a stranger's comment with401auth.unauthenticated, and the refusal names the field and the door that turns it on. (It says401and not403because the answer is "sign in or be allowed in", not "your account may not" — a signed-in account that may not reply is the403.) The composer is not even rendered: the reader waits for the answer to "may this person comment" before drawing a comment box, so a crawler, a feed reader and a reader with JavaScript off are never served an open "Your name" field. It is the same switch that governs anonymous reactions, and the two give the same answer: an unconfigured blog offers a logged-out reader no write path at all, andGET …/blog/t/{slug}reportsreactions.enabled: falseso the buttons are not drawn either. Sendnullto clear the choice and go back to following your organization's default.moderateAnonymousdefaults to on. WithallowAnonymouson, a stranger's comment is accepted withstatus: "pending"and does not appear on the page. Approve or hide it withPOST …/blog/posts/{postId}/status(visible,pending,hidden).GET …/blog/flagsis the flag queue.
whoCanPost has an organization default, community.who_can_post, that applies to every space which never chose one; the space's own field wins where it is set.
The other comment door: comment-settings
GET PUT /orgs/{orgId}/spaces/{spaceId}/comment-settings is a different door to the switches above, and the one people reach for first because of its name. It carries enabled and moderated for comments a reader leaves on a page — a docs page, a legal document, a careers posting. A blog post's comments are governed by the per-post switch and …/blog/settings, never by this.
It is a partial update. Both fields are optional: a field you leave out keeps the value it has, and the reply is the whole setting as it now reads rather than the fragment you sent. So PUT {"enabled": true} turns page comments on and does not touch moderation, PUT {"moderated": true} holds comments for review without answering whether they are on, and a body carrying both still sets both. It used to require both fields, which made the obvious request the wrong one — PUT {"enabled": true} answered 422, naming moderated — and a caller who added the second field to get past it could turn moderation off by accident (Crossly E-40).
Captcha
A comment from a reader with no account is challenged whenever a captcha is configured, with the organization's own key:
curl -sS -A 'my-app/1.0' -X PUT -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
https://forumulate.com/api/orgs/$ORG/captcha \
-d '{"provider": "turnstile", "siteKey": "0x4AAA…", "secret": "0x4AAA…"}'provider is turnstile or hcaptcha. The secret is write-only — GET /orgs/{orgId}/captcha answers configured: true and never the key. The reader renders the widget from GET /public/orgs/{orgId}/captcha, which serves the site key only, and the comment door then requires a solved token (captchaToken) from anybody it cannot name. A reader who signed in is not challenged; they have proved more than a captcha would.
With no captcha configured there is no challenge, and the protection on an anonymous comment is the moderation hold above. If you turn moderateAnonymous off, configure a captcha first.
Posting one
curl -sS -A 'my-app/1.0' -X POST -H 'content-type: application/json' \
https://forumulate.com/api/public/orgs/$ORG/spaces/$SPACE/blog/t/$SLUG/comment \
-d '{"authorName": "Ada", "content": {"type": "doc", "content": []}, "captchaToken": "…"}'parentPostId threads a reply under another comment. A signed-in reader comments as themselves and authorName is ignored — asking a known person to type their name into a box is how a comment section forgets who its readers are.
Front page
A blog's front page is a composed document. Until you edit it, it is generated from the index preset: the intro band, the search band and the posts feed (in the order you set), then subscribe and pagination. A blog feed sorts by latest, top or oldest and filters by tags, author, pinned, featured and section. A post's pinned (Pin to top) keeps it at the head of the default order; featured (Feature) puts it in a feed filtered to featured and makes it the opener of a magazine feed. Both are set with PATCH …/blog/{id}, and the public index takes featured, pinned and sort=oldest as query parameters.
Which layer draws the index
Three settings sound as if they all change the blog's index. They do not overlap: each owns a different part of the blog, and the order is fixed.
A stored front page wins outright. Once you save a front page (
PUT …/front-page, or the studio's front-page builder), the index draws that document and nothing else. The preset is no longer read for the index, and neither isfrontDoor.sectionOrder(the order of the movable bands in the generated page).DELETE …/front-pagegoes back to the generated one.No stored page: the preset generates one. The index preset (
PUT …/preset, Design → layout:feed,gridormagazine) is only thelayoutof the generated posts feed:title,intro, thensearchand the posts feed (aspaceFeed {controls: true, paginate: true, layout: <preset>}) infrontDoor.sectionOrder, thensubscribeandpagination. Any other preset reads asfeed.Inside a feed, the block's own attrs win. A
spaceFeedin a stored page that names nolayoutisfeed, whatever the preset says. Its card (card,lead,columns,fields, …) is whatever the block says, and unset keys are the card's defaults.
theme.blog.look is not one of these layers. Its patches set post.* keys (the post page) and blog.* keys (the hub pages: archive, series, authors, tags, search, a tag's head). They never reach the index or a front-page feed. So look: "magazine" and the magazine preset are different things that share a name: the look styles the post page and the hubs, and the preset arranges the index's generated feed. For a magazine blog throughout, set both, or set the look and compose the index yourself.
What does carry across: frontDoor switches such as showArchive, showRss and badgeStyle hold for a stored page and a generated one alike, and theme.blog.readTime.wpm scales every "N min read" on the index. A tag's landing page with no composed page of its own reuses the first feed of whichever front page is in force, stored or generated (see Categories on a blog).
Posts in other languages
Declare the languages first, as for docs: POST /orgs/{orgId}/spaces/{spaceId}/docs/locales with { "code": "es", "label": "Español" } (Studio → Languages). Then each post can have one translation per declared language — its title, excerpt and body — at …/blog/{topicId}/translations/{locale}:
PUTwrites or corrects one by hand (title,excerpt, andcontentas blocks ormarkdown); it is published unless you sendpublished: false.POST …/{locale}/autotranslates with your organization's own DeepL key. It never replaces a translation a person wrote unless you sendforce: true, and it stays unpublished until you publish it.GET …/translationslists every declared language withexists,origin(humanormachine),publishedandstale—stalemeans the original changed after the translation was made.DELETE …/{locale}removes one.
A translation lives at the post's own address in that language — the language is a path segment after the blog's address (/blog/es/my-post), the original has none. The index lists each post under its translated title where one is published and keeps the rest as written, so a half-translated blog still shows every post. Both reader reads answer locale (the language served; default is the original) and locales (each declared language, available when something on the page is published in it), and the reader draws a language row from them. Writing, replacing or removing a translation purges the post's page and the index. These are the same translations, rows and shapes a changelog release has.
Every language has its own feeds and markdown. /blog/es/rss.xml, /blog/es/feed.xml and /blog/es/feed.json carry each post in Spanish where it has a published translation (linking to /blog/es/<post>) and the rest as written (linking to the original), with the feed's language set. /blog/es/<post>.md is the post's translation as markdown, or the original when there is none. The same holds for a forum (/forum/es/rss.xml, latest.rss, top.rss, category, tag and <topic>/posts.rss feeds — replies stay as written), a changelog (rss.xml, atom.xml, feed.json) and a docs page's .md (the page's twin in that language). Each index page advertises its language's feed and names every other published language's feed with <link rel="alternate" hreflang>. On the API these are ?locale= on the feed and .md reads.
What a reader gets
GET /public/orgs/{orgId}/spaces/{spaceId}/blog is the index; …/blog/t/{slug} is one post with its comments, and …/blog/t/{slug}.md is the same post as Markdown. There are feeds at rss.xml, feed.xml and feed.json, an archive by month, a series list, and tags.
Who can read a post, and paid memberships
A post's access is public (unset), members, paid or password, and previewBlocks says how much of a gated post a stranger reads. The cut is made by the API on every door that hands out a body — the post read, its .md, the media zip, the feeds, search, oEmbed, AMP and the podcast feed. Paid memberships (monthly, yearly, founding, trials, group seats, gifts, offer codes, complimentary memberships), the paywall card, password posts and tips are in Blog memberships, paid posts and password posts.
Post formats
format says how a post is drawn, not what it says. standard (unset) is every post as it always was.
| How it reads |
|---|---|
| Title, standfirst, body. |
| A short note: no title on its card; the words are the card. |
| The standfirst set as a pull quote. |
| The title goes where the post points. |
| The media leads: a larger cover, the title under it. |
The blog's defaultFormat and defaultTags (above) are what a new post starts with. An audio post with audioUrl gets a player, and is an episode of the podcast when that is on.
The blog's look: theme.blog
theme.post is how one post reads. theme.blog is everything around the posts: the hub pages (archive, series, authors, tags, search, a tag's head), the addresses, the membership portal, AMP, recommendations and reading time. Set it with PATCH /orgs/{orgId}/spaces/{spaceId}/theme like any theme key; it is strict, so a misspelled key is a 422 naming its path, and every key unset is the blog exactly as it was. In the studio it is Blog: look and hub pages in the Design panel, beside Post page: every setting for theme.post.
Looks are patches
theme.blog.look picks one of five looks. paper (unset) is the blog as it was. Each of the other four is a patch: a handful of independent post.* and blog.* keys laid under yours, never stored. A key you set yourself wins under any look, and any key of one look can be mixed with another look, because each key means one thing on its own. The layers run theme → space → post: a post's own customization.theme.post beats both.
|
|
|
|---|---|---|
| none | none |
|
|
|
|
|
|
|
|
|
|
|
|
Three combinations no look ships, each one PATCH …/theme body:
{ "blog": { "look": "magazine", "hubs": { "width": "text", "cardStyle": "compact" } } }A magazine post page (split cover, eyebrow tags, card suggestions) with quiet, compact hub lists on the reading measure.
{ "blog": { "look": "minimal" }, "post": { "cover": { "show": true, "width": "wide", "aspect": "16:9" } } }Minimal's bare words and compact hubs, but every post still opens on a wide cover.
{ "blog": { "look": "newsletter", "tagsIndex": { "layout": "cards" }, "series": { "numbering": "plain" } },
"post": { "dropCap": { "style": "raised", "lines": 2 } } }The newsletter column, with editorial's drop cap (raised, not sunk), a tags page of cards and plain part numbers.
Hub pages
Key | Values (default first) | What it does |
|---|---|---|
|
| The hub column's width. |
|
|
|
|
| How every hub lists its posts: one column, a two-up grid, or title-and-meta rows. |
|
|
|
|
| On a phone: title and meta only, and/or no covers. |
|
|
|
|
| The post count beside each month. |
| off | Each month's post titles under it on |
| "Archive" | The archive's heading. |
| off | A month calendar on the archive and each month's page; every day with posts links to that day. |
|
| A part's number: |
| shown | "4 parts, in reading order", and the small "A series" line. |
| 80 | The portrait, 24–200 px. |
|
| Portrait beside the name, or above it with everything centred. |
| shown | The author's links, job title, |
|
|
|
| shown | Each tag's post count. |
| hidden on pills, shown on cards and list | Each tag's description. |
|
| A hit as a title line or a card with its cover. |
| shown | The date under a hit; the cover beside it (cards). |
| shown |
|
| shown | The "Tagged" line and "12 articles". |
|
| The tag cover's proportion. |
|
|
|
| 200 | Reading speed, 80–600 words a minute; every "N min read" scales from it. |
Composed hub pages. Every hub can also be composed from blocks, like the front page — studio → Hub pages, or PUT /orgs/{orgId}/spaces/{spaceId}/blog/hubs/{hub}/{key}/page with {"doc": …}. {hub} is archive, month, seriesIndex, series, authors, author, tags or search; {key} is one instance (2026-10, a series name, an author key) or _ for the page every instance of that hub wears unless it has its own. A spaceBand title places the hub's own head, a spaceFeed of this blog with controls is the hub's own list, and any other feed of this blog is narrowed to the hub (its author, its series). GET reads it, DELETE puts the generated page back. A tag's landing page keeps its own door (…/blog/tags/{slug}/page, under Blog and Changelog spaces).
Addresses and pages a blog has
Under the blog's address (/b/<org>/<space> on the platform, the blog's mount on your own domain):
Address | What it is |
|---|---|
| A post (or |
| The post as Markdown. |
| The archive, a year, a month, a day. A period nobody published in is a 404. |
| Every series; one series in reading order. |
| Every author; one author's page. |
| Every tag; a tag's landing page. A child tag's posts are found on its parent's page too. |
| Search. |
| The publications you recommend (below). |
| The plans, gifts, the redeem box and the reader's own membership (Blog memberships). |
| The newsletter's own page; |
| The blog's feeds. |
| One tag's feeds; the same three under |
| The podcast feed, when the podcast is on. |
| A post's AMP copy, when AMP is on. |
| The IndexNow key, when IndexNow is on. |
Each language a post is translated into has the same feeds under its language segment (/es/rss.xml), as above.
Permalinks
theme.blog.permalinks chooses the addresses:
Key | Default | What it does |
|---|---|---|
|
|
|
|
| A word of yours for the tag pages: |
|
| The same for author pages. |
|
| The same for series pages. |
The canonical address follows your choice, and every other spelling keeps answering, so no link already out there breaks — a move from /<slug> to dated addresses costs nothing.
Feeds
The front door's feed settings (PUT …/preset with frontDoor, or the front door's settings in the studio) apply to every feed of the blog:
| Default | What it does |
|---|---|---|
|
| How many posts a feed carries: |
|
|
|
| on | The cover in every format (it used to ride only in the JSON Feed). |
| the blog's name | The feed's title. |
| the blog's description | The feed's description (up to 240 characters). |
| the reader's default | What an empty list — the index, or a hub with nothing in it — says. |
|
| The subscribe control a |
oEmbed and AMP
Each post page advertises an oEmbed endpoint (GET /public/orgs/{orgId}/spaces/{spaceId}/oembed?slug=<slug>, format=json only, maxwidth 120–2000): pasting a post's address into a tool that reads oEmbed gives a rich card of plain markup — title, summary, picture, link — that runs nothing of ours on the page it lands in. An email-only post has none.
theme.blog.amp: true gives every public post an AMP copy at /amp/<slug>, linked from the post with rel="amphtml" and canonical to the post. It is off unless you choose it, because an AMP page loads the AMP runtime from Google's CDN — a third party you opt your readers into. Gated posts have no AMP copy.
Recommendations
Studio → Recommendations, or PUT /orgs/{orgId}/spaces/{spaceId}/blog-recommendations: up to 30 publications you recommend, each {name, url, description, imageUrl} (imageUrl https). They are listed at /recommendations, and theme.blog.recommendations shapes the page and where it is offered:
Key | Default | What it does |
|---|---|---|
| "Recommendations" | The page's heading. |
| shown when there are any | A link beside the archive's in the index nav. |
|
|
|
| off | Offer them right after a reader subscribes. |
The same body takes discoveryBlurb: one line about your blog for the public blog directory (below). Its old discovery field was an opt-in to a directory that was not shown yet; it is still accepted and no longer decides anything.
The public blog directory
Forumulate keeps a public directory of the blogs published with it, at forumulate.com/blogs, linked from the footer of our own site. It is server-rendered, in our sitemap, and has one canonical address, so search engines can find it and, through it, your blog.
Every public blog is listed by default. You can leave it out two ways:
This blog: Studio → Recommendations → untick List this blog in the Forumulate blog directory. By API it is the space's
listedswitch (settings.discovery.listed, defaulttrue):PUT /orgs/{orgId}/spaces/{spaceId}/discoverywith{ "listed": false }.GETon the same address reads it back.Every blog of your organization: the organization setting
discovery.blog_directory(Settings → SEO, List our public blogs in the Forumulate blog directory), on by default. Turned off, it wins over every blog in the organization, whatever each blog's own switch says.
Some blogs are never listed, whatever either switch says:
a private space;
a members-only blog (one behind a reader sign-in or password gate);
a paid-only blog (one where every published post is for members, paying members or password holders, so a reader would find nothing to read);
a blog that has published nothing yet;
a blog marked noindex (Studio → SEO → hide from search engines);
a blog on a network allow-list (the space's own, or one on its custom domain).
Each entry shows the blog's name, its organization, your one-line discoveryBlurb and a link to the blog's canonical address (your custom domain when it has one). The most recently active blogs come first. The list is refreshed every 30 minutes, so a change takes up to half an hour to show. GET /public/discover/blogs answers the same list as JSON.
Podcast
Studio → Podcast, or PUT /orgs/{orgId}/spaces/{spaceId}/podcast. Off unless switched on; a blog that never set it has no podcast feed.
Key | Default | What it does |
|---|---|---|
|
| Publish |
| empty | The show's name, author and description (up to 4,000 characters). |
| empty | The owner address Apple asks for. It is in the feed, so use a role address. |
| empty | An Apple Podcasts category ("Technology"). |
|
| The show's explicit flag. |
| empty | Square artwork, 1400–3000 px, https. |
|
|
|
Every public post with an audioUrl is an episode, with audioDuration, audioBytes and episode filling the iTunes tags Apple Podcasts and Spotify read. A members-only or paid episode is left out of the feed.
More on the post page
These are theme.post keys like the ones in Blog and Changelog spaces, so each also works under post.byTag and on one post's own theme. Unset is the page as it was.
Key | Values | What it does |
|---|---|---|
|
| The post's own Further reading links, after the words. Shown when the post has some. |
|
| The previous and next post. |
|
| The comments' look. |
|
| The card where a gated post's preview stops (Blog memberships). |
|
| A "Sponsor this blog" box; with tips on, one-off tips too. |
|
| Readers' marks, each off unless on: claps (up to 50 per reader), highlights and the post's top highlight above the body, private notes on a passage, restacks with a line of the reader's own, and the signed-in reader's reading streak. Counts and the top highlight are public; a note comes back only to its writer; every mark needs a signed-in reader. |
|
| "Was this helpful?" at the foot; |
|
| The post on paper: its address and cover are printed unless |
| boolean | Select words and a small bar offers to share or copy the quote with a link back. Nothing third-party is asked anything until the reader picks a network. |
|
| Where "more to read" goes. The Suggested layout is now this key ( |
| boolean | The on-this-page column. The Contents layout is now this key; a post's own |
Newsletter
Every blog has a mailing list (double opt-in). Studio → Newsletter, or PUT /orgs/{orgId}/spaces/{spaceId}/newsletter (partial; GET reads it), makes it a publication. Every key defaults to what the list did before, so a space that sets nothing mails exactly as it did.
Key | Default | What it does |
|---|---|---|
| empty | What the list is called on its signup form, its |
| the organization's | The display name on the From line. No quotes or angle brackets. |
| empty | The From address. Used once your organization sends through its own mail provider (Settings → Email); until then your |
| the From address | Where a reply goes. |
| none | A banner at the top of every issue (https only). |
|
|
|
|
| The post's cover in the issue. |
|
| The post's excerpt. |
|
| "By <author>" under the title. |
|
|
|
| "Read the article" | The button's words. |
| empty | Small print at the foot of every issue and broadcast. |
|
| Up to 10 named newsletters, |
|
| Who a publish mails when the post names nobody: subscribers with any of these labels. Empty is everyone. |
|
| A referral programme: every issue carries the subscriber's own link, and every |
The design keys build the issue's layout. It sits under any template you wrote in Email templates, which still wins.
Per post. A post's delivery (both, web, email), newsletters (list ids), emailTags (labels) and emailNote (a paragraph only the email carries) decide where it goes and who gets it; see Per-article fields. An email-only post is unlisted: it has no web page, feed entry, sitemap entry or search hit.
Test sends. POST …/newsletter/test-issue with {"topicId"} mails you the issue a post would send (blog.author); POST …/newsletter/test-broadcast with {subject, body} mails you a draft broadcast. Both are 10 a minute and record nothing. In the studio: Email me this post as a test and Send me a test.
Broadcasts to part of the list. POST …/blog/broadcasts takes lists (only the subscribers of those newsletters), segment (subscribedAfter, subscribedBefore, tags — any of them) and test: true (to you only).
Importing subscribers. POST …/newsletter/import with a CSV whose header names email (and optionally tags, name):
curl -sS -A 'my-app/1.0' -X POST \
-H "Authorization: Bearer $FORUMULATE_TOKEN" -H 'content-type: application/json' \
https://forumulate.com/api/orgs/$ORG/spaces/$SPACE/newsletter/import \
-d '{"csv": "email,name\nada@example.com,Ada\n", "mode": "optin", "tags": ["imported"], "lists": ["weekly"]}'mode: "optin" (the default) sends each address the confirmation email. mode: "subscribed" adds them as subscribed and requires consent: true, your statement that these people agreed elsewhere — skipping the double opt-in is an act, not a default. An address that unsubscribed here is never re-added. The answer counts added, updated, skipped and invited, with each bad row and why.
Where signups come from. The subscribe body takes ref (a page's ?ref=, a campaign or a referral code) and name (with theme.blog.portal.nameField). Each subscriber records its source, and GET …/blog/subscribers.csv exports name, tags and source with the address.
The signup form elsewhere. /subscribe?embed=1 is the bare form, sized for an iframe on your own site; Newsletter → Signup form for other sites has the snippet. The newsletter's own page is the same address without ?embed=1.
Opens and clicks. Only with the organization setting community.newsletter_tracking on (off by default) does an issue carry an open pixel and rewritten links; they are counted per post in Readers. Whether you need your readers' consent for that is yours to manage.
Readers
Studio → Readers is each post's numbers over the last 30 days (1–365 with ?days=): views, unique readers, call-to-action clicks, helpful and unhelpful votes, comments, and — with tracking on — email opens and clicks, plus subscriber growth. GET /orgs/{orgId}/spaces/{spaceId}/engagement reads it, …/engagement/export.csv downloads it, and …/engagement/comments lists what readers wrote under "Was this helpful?". A changelog has the same panel.
Publishing
Review before publishing. An author sends a draft for review (POST …/blog-review/{topicId}/review, Send for review); a publisher approves it (…/review/approve) or asks for changes with a note (…/review/request-changes); DELETE …/review withdraws it. With the space setting requireReview: true, only an approved post publishes — off unless you choose it, and a blog does not take the changelog's organization default. Webhooks: blog.review_requested, blog.reviewed.
Publishing pings. Studio → Publishing pings, or PUT /orgs/{orgId}/spaces/{spaceId}/publish-pings. All off until switched on:
Key | Default | What it does |
|---|---|---|
|
| Tell Bing, Yandex, Seznam and Naver about a new address at once. The key file is served at |
|
| Up to 10 XML-RPC |
|
| Tell every page a post links to, where it accepts webmentions. |
|
| Receive other sites' mentions of your posts ( |
|
| Show received mentions without review. Unset, each waits: |
A changelog takes the same settings.
Post by email. GET …/blog-email-in (studio: Publishing pings → Post by email) gives the blog a private address. Mail to it from an organization member's address becomes a draft — the subject is the title, the message the body, attachments the pictures; mail from anyone else is dropped. Keep the address private.
GitHub sync and backup. Through the space's Git connection (the one docs sync uses), POST …/blog-github/sync reads every *.md under the connection's path — front matter title, slug, date, tags, excerpt, cover, draft — and writes each as a post (new slugs created, existing ones updated, nobody mailed). POST …/blog-github/export writes every published post back as <path>/<slug>.md with the same front matter, one commit per changed file.
Stock photos and GIFs. Studio → Stock media takes your own Unsplash and GIPHY keys (PUT …/media-search/keys with unsplash, giphy; null removes one). The editor then searches both; choosing an Unsplash photo tells Unsplash, as its terms require. Keys are stored encrypted and never sent back.
Importing. WordPress, Ghost, Substack and Medium exports, or one article by its address, come in through Studio → Import (POST …/blog-import): each post keeps its title, address, date, tags, author, cover and canonical URL; a post whose address the blog already has is skipped, and nobody is mailed. Every importer is described in Importers.
Federation. A blog as an ActivityPub actor is covered in ActivityPub federation.
Tags in depth
On top of the display fields in Blog and Changelog spaces, a tag (PUT …/blog/tags/{slug}) takes:
parent— the tag it sits under (hierarchical categories). The parent's page lists its children, and a post tagged with the child is found on the parent's page too.nullis top level; a tag under itself or its own child is a422. Studio: Sits under.translations—{"de": {"label": "KI", "description": "…"}}, the label and description per language, up to 30. The reader shows the one for the page's language. Authors taketranslationstoo, withroleandbio. Studio: In other languages.internal: true— an internal tag: it groups posts for you and is never shown to a reader.
Categories on a blog
A blog has no separate "category" object. Its tags are its categories: a tag has a display name, colours, a gradient, a cover, an icon, a description, a parent and a page of its own, which is everything a category needs. Use a handful of top-level tags as sections ("Engineering", "Design", "News") and give each one a look:
PUT /orgs/{orgId}/spaces/{spaceId}/blog/tags/engineering
{ "label": "Engineering",
"color": "#0f766e", "textColor": "white",
"colorDark": "#5eead4", "textColorDark": "#042f2e",
"gradient": ["#0f766e", "#2563eb"],
"icon": "code",
"coverUrl": "/api/assets/…",
"description": "How we build it, and what broke on the way.",
"pageScheme": "dark", "pagePalette": { "primary": "#0f766e" } }Key | What it is | Where a reader sees it |
|---|---|---|
| The display name; the slug stays the id. | Every chip, the tags band, |
| The chip's fill and ink (a | Chips on cards and posts, the tags band's pills (the active pill takes the tag's colour), |
| The same in a dark scheme. Unset: | As above, when the page is dark. |
| 2–3 | The |
| A glyph name (any name in | Before the label on chips, kickers and the tags band's pills, on the blog and the forum. |
| An image. | The head of the tag's page ( |
| Up to 500 characters. | The tag page's head and meta description, |
| The scheme and palette slots the tag's pages wear. | The tag's page. |
| The tag it sits under. | The parent's page lists its children and finds their posts. |
The order categories appear in is yours where it matters: the tags band and a feed's tag tabs list the tags you name, in that order (unset, the band shows the most-used eight, or limit of them). /tags lists every tag by post count, most first.
Recipes for a magazine front page
Each is a PUT /orgs/{orgId}/spaces/{spaceId}/front-page body; every key below is in GET /docs/block-schema.
Category tabs over the index. The tags band is the row of categories (style: pills, pills-filled, tabs, text or segmented). Each tab wears its tag's colours and icon, links to the tag's page, and the current one is marked there:
{ "doc": { "type": "doc", "content": [
{ "type": "spaceBand", "attrs": { "band": "title" } },
{ "type": "spaceBand", "attrs": { "band": "tags", "style": "pills-filled",
"tags": ["engineering", "design", "news"], "showAll": true, "overflow": "scroll" } },
{ "type": "spaceFeed", "attrs": { "layout": "grid", "controls": true, "paginate": true } }
] } }A featured opener, then one row per category. lead.source: "featured" opens the magazine on the newest featured post wherever it is — however old, whatever limit is — and fills the rest with the newest others (nothing featured: the newest post opens). dedupe: true keeps everything the magazine drew out of the rows under it. Each category row is a feed filtered to one tag, with the tag as its kicker:
{ "doc": { "type": "doc", "content": [
{ "type": "spaceFeed", "attrs": { "layout": "magazine", "limit": 4,
"lead": { "source": "featured", "style": "split", "titleSize": "xl" } } },
{ "type": "spaceFeed", "attrs": { "heading": "Engineering", "layout": "grid",
"columns": { "sm": 1, "md": 3, "lg": 3 }, "limit": 3, "dedupe": true,
"filter": { "tags": ["engineering"] },
"card": { "kicker": "firstTag", "tagStyle": "eyebrow" },
"moreLink": { "label": "All engineering" } } },
{ "type": "spaceFeed", "attrs": { "heading": "Design", "layout": "grid",
"columns": { "sm": 1, "md": 3, "lg": 3 }, "limit": 3, "dedupe": true,
"filter": { "tags": ["design"] },
"card": { "kicker": "firstTag", "tagStyle": "eyebrow" } } }
] } }Give moreLink an href to the tag's page; with none it links to the blog itself. A row can cover several tags: filter.tags takes up to ten, joined by tagMatch (any, unset, or all), and filter.excludeTags leaves some out.
One feed, the categories as tabs over it. tabs.tags filters the feed in place instead of leaving the page:
{ "type": "spaceFeed", "attrs": { "layout": "magazine", "limit": 7,
"tabs": { "tags": ["engineering", "design", "news"], "allLabel": "Everything",
"style": "segmented" } } }Category pages that match. A tag's page (/tag/<slug>) opens with the tag's head (name, description, colour, cover; theme.blog.tagHead shapes it) and then the first feed of the front page in force, narrowed to the tag and paginated. So a magazine front page gives magazine category pages without composing anything. To design one category's page, compose it: PUT …/blog/tags/{slug}/page (see Blog and Changelog spaces). A /tags page of category cards with covers and descriptions is theme.blog.tagsIndex.layout: "cards", which look: "magazine" also sets.
One post's own look
A post's customization.theme may set the token keys (colours, palettes, fonts, typography, colorScheme, components, widths, layout, motion) and the post namespace — every key on this page and in How a post reads, including paywall, sponsor, marks and comments. Another type's namespace (changelog, docs, forum, …) is refused with a 422 naming it, and so is blog: the hubs, addresses and looks are the space's alone. In the studio it is This piece's theme in the post's details.
{ "customization": { "theme": {
"post": { "layout": "split-cover", "paywall": { "style": "band", "heading": "The rest is for supporters" } }
} } }Outlets
A theme component can place HTML in these blog outlets. None wraps; the post outlets carry post.title and post.slug.
Outlet | Pages | Where |
|---|---|---|
| index | The blog index, above the list of posts. |
| index | Below the list and its pager. |
| index, hub | The foot of every post card (repeats; |
| hub | Archive, an author, a series, a tag, search — above the list. |
| hub | The same pages, below the list. |
| post | Above a post's body. |
| post | After its body. |
| post | After everything that follows the post (suggested posts, the piece page), before the comments; as wide as |
Where it is in the studio
The blog's studio sections: Posts, Languages, Comments and community (the settings table above, default format and tags), Hub pages, Newsletter, Readers, Import, Recommendations, Podcast, Stock media, Publishing pings (with post by email), Paid memberships, Moderation and Comments on a post. A post's own fields — access, preview length, format, audio, delivery, newsletters — are in its details. The look is in the Design panel: Blog: look and hub pages and Post page: every setting.
What you need to set up
Paid posts, memberships, gifts and tips: your organization's Stripe, under Settings → Payments (Payments and the shop). Until then readers of a
paidpost see the preview and the card with no plans. Details in Blog memberships.A newsletter From address on your domain: your organization's own mail provider (Settings → Email). Until then issues come from our address under your
fromName.Open and click counts: the organization setting
community.newsletter_tracking. Until then the Readers panel shows no email numbers.Stock photos and GIFs: your own Unsplash and/or GIPHY key under Studio → Stock media. Until then the picker says where to add one.
GitHub sync and backup: a Git connection on the space.
Post by email: inbound mail on Forumulate's side; while it is not available the address reads empty (
null).AMP, IndexNow, update services, webmentions, the podcast, recommendations: each is a switch of yours, off until you turn it on.
The public blog directory: on by default; untick it per blog, or turn off
discovery.blog_directoryfor the whole organization (above).
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.
Blog Comments
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Create the vote in a space (blog posts) |
| |
| Create the react in a space (blog posts) |
| |
| Update the post (blog) |
| |
| Delete the post (blog) |
| |
| List revisions in a space (blog posts) |
| |
| Get the revision (blog) |
| |
| Flag the post (blog) |
| |
| Create the status in a space (blog posts) |
| |
| Create the recover in a space (blog posts) |
|
Blog Email In
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Read a blog’s post-by-email address |
|
Blog Engagement
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Read each post’s reader numbers |
| |
| Download each post’s reader numbers as CSV |
| |
| Read the changelog widget’s NPS |
| |
| Read the feedback readers left on posts |
|
Blog Github
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Bring Markdown posts in from the connected GitHub repository |
| |
| Back up published posts to the connected GitHub repository |
|
Blog Media Search
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Which stock-media providers have a key |
| |
| Set or remove the stock-media providers’ keys |
| |
| Search stock photos or GIFs |
| |
| Record that a stock photo was used |
|
Blog Moderation
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List flags in a space (blog) |
| |
| Create the review in a space (blog flags) |
| |
| Resolve the flag (blog) |
|
Blog Newsletter
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List subscribers in a space |
| |
| File of subscribers |
| |
| List broadcasts in a space (blog) |
| |
| Create a broadcast in a space (blog) |
| |
| Replace tags in a space |
| |
| List subscriber tags in a space |
|
Blog Publish Pings
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Read who a space tells when it publishes |
| |
| Change who a space tells when it publishes |
| |
| List the webmentions received |
| |
| Approve or reject a received webmention |
|
Blog Recommendations
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Read a blog’s recommendations |
| |
| Change a blog’s recommendations |
|
Blog Review
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Send a draft blog post for review |
| |
| Take a blog post out of review |
| |
| Approve a blog post under review |
| |
| Ask for changes to a blog post under review |
|
Blog Taxonomy
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the page in a space (hubs) |
| |
| Replace the page in a space (hubs) |
| |
| Delete the page in a space (hubs) |
| |
| Get the tag info in a space (blog) |
| |
| Replace the tag |
| |
| Delete the tag |
| |
| Get the page in a space (tags) |
| |
| Replace the page in a space (tags) |
| |
| Delete the page in a space (tags) |
| |
| List authors in a space (blog) |
| |
| Replace the author (blog) |
| |
| Delete the author (blog) |
|
Public Blog Feeds
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| RSS feed of the blog (rss.xml) |
| |
| RSS feed of the blog (feed.xml) |
| |
| The blog's posts as an Atom feed |
| |
| JSON feed of the blog |
| |
| One tag's posts as an RSS feed |
| |
| One tag's posts as an Atom feed |
| |
| One tag's posts as a JSON Feed |
| |
| One author's posts as an RSS feed |
| |
| One author's posts as an Atom feed |
| |
| One author's posts as a JSON Feed |
| |
| One series' posts as an RSS feed |
| |
| One series' posts as an Atom feed |
| |
| One series' posts as a JSON Feed |
|
Public Blog Newsletter
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Read a public space’s newsletter (name and lists) |
|
Public Blog News Sitemap
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| The blog’s Google News sitemap |
|
Public Blog Podcast
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| The blog’s podcast feed |
|
Public Blog Recommendations
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Read a public blog’s recommendations |
| |
| List the publications in the platform directory |
|
Public Captcha
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the captcha in an organization (public reader) |
|
Public Faq Channels
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| An FAQ's new and changed answers as RSS ( |
| |
| An FAQ's new and changed answers as Atom ( |
| |
| An FAQ's new and changed answers as JSON Feed ( |
| |
| The whole FAQ, every question and answer, as one markdown document |
| |
| The FAQ help widget's questions, with those suggested for a page |
|
Public Publish Pings
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Send a webmention to one of this space’s posts |
| |
| List a post’s approved webmentions |
| |
| This space’s IndexNow key |
|
Public Space Authors
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List authors in a space (public reader) |
| |
| Get the author |
|
Space Authors
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List authors in a space |
| |
| Replace the author |
| |
| Delete the author |
|
Space Settings comment settings
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List comment settings in a space |
| |
| Turn reader comments on, and whether they are moderated |
|