Docs/Guides
Blog and newsletter
Write posts in the panel's editor and publish them on a hosted blog at
/blog/<public key>, inside your own site with one script tag, in an RSS feed,
and in your subscribers' inboxes. Readers react to posts and comment on them.
Much of what Ghost does is here: standalone pages, featured and members-only
posts, a designed front page with navigation and search, what search engines
see, a sitemap, publish-and-email, and an import from Ghost.
Panel: Project → Blog (Posts, Pages, Comments, Design, Settings); the native
app has the same screens.
Everything here is in both editions. In HelpBites Cloud, emailing a post to subscribers is a campaign, so it needs a plan with campaigns (Pro); the blog, comments, reactions and RSS are on every plan.
Setup#
- Project → Blog → Settings → Public blog on. Until then nothing is public, not even published posts.
- Optionally name it and describe it (the name shows at the top and in the feed; it defaults to "<project> blog").
- Pick what readers may do: Reactions and Comments (both on by default), and who is emailed about new comments.
- If your posts also live on your own site (rendered from the API), set
Post address on your site, for example
{siteUrl}/blog/{slug}. The feed, the newsletter and the hosted page's canonical link then point there. - Design: the hosted blog's layout, typeface, colours, logo, cover, navigation, social links and footer (below).
- Moving from Ghost? … → Import from Ghost (below).
Writing#
The editor is Nuxt UI's (TipTap): headings, bold, italic, strikethrough, code,
lists, quotes, code blocks, dividers, links and images. Type / for blocks;
select text for the formatting bubble. Images are uploaded to the project's
storage (8 MB each, PNG, JPEG, GIF or WebP); they count against the workspace's
storage. See Smaller images to send them
smaller.
- Saved as Markdown. That is what the API takes and returns as
body. It is rendered once on save intobodyHtml, through an allowlist: no scripts, forms, inline styles or event handlers, and no frames except a YouTube or Vimeo player (sandboxed; in the newsletter it becomes a link to the video). Anh1in the body becomes anh2(the title is the page'sh1). - Or HTML. A post imported from Ghost keeps its HTML (
bodyFormat: "html"); the editor edits it as HTML, and it goes through the same allowlist. The API takesbodyFormat: "html"too. - Drafts save themselves a moment after you stop typing. A live or scheduled post keeps your edits until you press Update, so half a sentence never goes public. ⌘S / Ctrl+S saves either way.
- Write with AI. The Write with AI button (web and the native app) reads
the project's connected GitHub repos (read-only: README, manifests and file
list, three repos at most) and the post as it stands, and proposes Markdown:
write a whole post from a one-line brief, continue it, improve its
text, or suggest a title, excerpt, search description and tags. Nothing is
saved until you apply the proposal to the editor, where it saves like any
edit. In the web editor it is also a block: type
/and pick Write with AI to describe a passage for the cursor, or select text and press the sparkle in the formatting bubble to rewrite just that (say how, or leave it empty to tidy it). It runs on the workspace's AI provider (or the server's gateway, in AI credits), is told not to invent features the repos do not show, and is not offered for a post kept as HTML. See over the API. - Settings beside the editor: status, publish date, address (
slug), Featured, Who can read it (everyone, or members only), excerpt, cover image and its alt text, up to 10 tags, author, whether the post takes comments, and Search and social: a title and description for search engines (with a preview), the image link previews show, and a canonical address for a post that first appeared elsewhere. Each falls back to the post's own when empty.
Smaller images and modern formats#
An upload can be re-encoded on the way in (Image uploads in the panel, next to
the cover and design pickers and in the editor; remembered in the browser, smaller
by default). Over the API it is off unless asked, with POST /v1/admin/blog/images:
| Field (or query parameter) | |
|---|---|
compress=true |
One file: WebP at quality 80, scaled down to 2560 px when larger. A GIF is kept as sent, and so is any file that would not come out smaller. |
formats=avif,webp |
A set for a <picture>: the formats asked for that came out smaller than the fallback (AVIF, then WebP), and the fallback every browser, app and mail client reads: a JPEG for a JPEG, a PNG for anything else (so transparency survives). Implies compress. |
The answer adds compressed and originalSize; with formats, url is the
fallback, variants lists every stored file (most efficient first, the fallback
last) and picture is a ready-made <picture>. Every file counts against the
storage; the 8 MB limit is on what was sent.
curl -s -X POST https://YOUR-SUPPORT-HOST/v1/admin/blog/images \
-H "Authorization: Bearer $HELPBITES_ADMIN_KEY" -F project=PROJECT_ID -F formats=avif,webp -F file=@shot.png
# { "url": ".../x.png", "variants": [{ "format": "avif", "url": ".../x.avif", … }, { "format": "webp", … }, { "format": "png", … }],
# "picture": "<picture><source type=\"image/avif\" srcset=\".../x.avif\"><source type=\"image/webp\" …><img src=\".../x.png\" …></picture>", … }A post's body allows <picture> and <source>, so paste picture into an HTML
post (or into Markdown, which passes it through the same filter) and readers get
the smallest format their browser reads. The editor's own uploads always send one
smaller file. The newsletter keeps only the fallback <img>.
Your own data on a post#
A post can carry a JSON object of your own, data (up to 200,000 characters as
JSON), for a site that renders its posts itself from the API: steps, a gallery,
settings, anything the Markdown body can't say. It is saved with POST /
PATCH /v1/admin/blog/posts, null clears it, and it comes back as data on
the post (admin) and on GET /v1/blog/<slug> (public, with bodyHtml; not in
the list, and not on a post that is locked). The hosted blog, the embed, the
feed and the newsletter ignore it. Keep the body readable on its own: that is
what they show.
Publishing#
| Publish | Live now. The date is now unless you set one. |
| Schedule | Publish with a later date: it goes live on its own at that moment (the hosted page, the API and the feed all wait for it). |
| Publish and email / Schedule and email | The arrow beside Publish: publishes (or schedules) and opens the newsletter. A scheduled post's newsletter waits for it. |
| Unpublish | Back to a draft (… menu). The date stays for when it comes back. A newsletter scheduled with it goes back to a draft too. |
The slug comes from the title ("Cómo empezar" → como-empezar). A slug another
post has gets -2, -3…; comments, rss, feed, tags and subscribe are
reserved. Changing the slug of a live post breaks links to the old one.
The excerpt is the lead-in on the list, in the feed and in link previews. Leave it empty and it is taken from the text (headings left out), and follows the text as you edit.
A featured post is shown first: large above the rest on the magazine layout,
marked on the others; ?featured=true on the API lists only those.
Pages#
Pages (the tab next to Posts) are for what stands on its own: About, Contact,
a reading list. They are written and published like posts and live at the same
addresses (/blog/<pk>/about), but stay out of the list, the feed, the
newsletter and webhooks, and take no comments, reactions, tags or author. Link
them from the blog's navigation (Design → Navigation, /about). The API
lists them with ?type=page.
Members-only posts#
Set Who can read it → Members only and everyone sees the title, excerpt and
cover, while the rest is for members: a user your app signed in (an identity
token, as for comments) or a reader who confirmed their email with the 6-digit
code (needs Allow email-only users). On the hosted page the post shows its
lead-in and a sign-in; once the reader is in, the rest appears in place. The API
returns such a post locked, without bodyHtml, until then; the feed carries
only its excerpt and a link; its comments are for members too. Search engines are
told it is behind a sign-in (isAccessibleForFree: false), so the gate is not
read as cloaking.
There are no paid tiers (yet): members-only means signed in, not paying.
Readers: reactions and comments#
Reading is open to everyone. Reacting and commenting need to know who the reader is, in one of two ways:
- Your user, signed in on your site. Hand the embed the same identity token
the chat uses (CONNECTING.md): your server signs it with the
project secret. Comments then carry your user's name and avatar. The
role: "admin"claim marks your own staff, who also moderate. - Just an email address. With Project → Settings → Allow email-only users on,
readers of the hosted blog (or of an embed without
getToken) sign in with their address; after a few writes they confirm it with a 6-digit code.
Reactions are ❤️ 👍 🎉 😄 🚀 👀, one of each per reader, toggled. Comments are Markdown, up to 4,000 characters, rendered with a strict allowlist (no raw HTML, images or headings, at most 6 links). Replies are one level deep: a reply to a reply joins the thread of the comment it answers.
Rules:
- Five comments per ten minutes per reader; the same text twice in a row on one post is refused (409).
- The author edits and deletes their own comment. A deleted comment's words are gone; replies to it stay.
- Moderation: Project → Blog → Comments lists every comment, newest first. Hide (only the team then sees it), show, delete, or reply: the team's comments are marked Team.
- Close a thread per post (Comments on this post), or turn comments off for the whole blog. Old comments stay readable.
- A person erased with
DELETE /v1/end-users/{externalId}(or from their contact) loses their comments and reactions too.
Newsletter#
Any published post can go to your subscribers: Send as newsletter beside the editor (admins). It becomes a campaign (MARKETING.md), so it has everything campaigns have: one message per subscribed contact with an email, topics, segments, test sends, pause and cancel, the delivery log and its numbers, under Email → Campaigns.
- The email is built from the post when sending starts: your logo, the title, date and reading time, the cover, the body (styled for mail clients), a Read it on the blog button and the footer with the unsubscribe link, the preferences link and your postal address.
- Postal address required (Email → Settings), as for every campaign.
- Audience: subscribed contacts with an email. Topic (default: the blog's Default topic) leaves out whoever opted out of it; a segment narrows it.
- Send now, Schedule, or Save as a draft to send a test from the campaign first. For a scheduled post, Send when published schedules the email for the moment the post goes live, and a later time is fine, an earlier one is not; reschedule the post later and the email follows.
- A post goes out once. A draft campaign is reused if you send again; only a canceled one can be replaced. Editing the post afterwards changes the blog, not the email that went.
Readers subscribe through the sign-up form under each hosted post (Sign-up form under posts in Settings), any other sign-up form of yours (MARKETING.md), or your own app.
RSS#
GET /v1/projects/<public key>/blog.rss: the latest 30 live posts, newest first,
as RSS 2.0 with their full HTML (content:encoded), cover, author and tags. A
members-only post carries its excerpt and a link instead of its body; pages are
left out. The hosted pages link to it for feed readers.
The hosted blog#
/blog/<public key> is server-rendered HTML, readable without JavaScript and
meant to be indexed. Project → Blog → Design sets how it looks:
| Layout | List (one column), Grid (cards), or Magazine (the newest featured post, or the newest post, large above a grid). |
| Typeface | Sans serif or serif, for the whole blog. |
| Colour scheme | Follow the reader's system, or always light or dark. |
| Accent colour | Links, buttons and tags; the project's accent unless you pick one. |
| Logo and cover image | Beside the name, and a wide image across the top of the front page. |
| Navigation | Up to 8 links in the header: / (the blog), /<page slug> (a page), or any address. |
| Social links | X, Bluesky, Mastodon (with rel="me", so Mastodon verifies it), GitHub, LinkedIn, Instagram, YouTube, a website. |
| Footer | A line at the bottom of every page. |
Beyond the look, every page has a search box (?q= finds words in titles,
excerpts and tags; result pages are noindex), tag pages (?tag=), and paging.
A post has its date, author and reading time, its tags, Newer and Older
links, and up to three Keep reading posts that share a tag. For search
engines and link previews: the title, description, image and canonical address
from Search and social, Open Graph and Twitter cards, and structured data
(Blog, BlogPosting, WebPage). The sitemap is
/blog/<public key>/sitemap.xml (shown in Settings), listing the front page and
every live post and page whose canonical copy is the hosted one: hand it to
Google Search Console.
The design is the hosted blog's. The embed takes your page's look instead, and
the layout (layout below).
Put it on your site#
One script tag draws the blog inside your page, no iframe, so it takes your
page's width and fonts. The list opens each post in place, with its reactions,
comments and the sign-up form; the open post is kept in the URL (?post=slug),
so links and the Back button work.
<div id="blog"></div>
<script src="https://YOUR-SUPPORT-HOST/blog.js" data-project="pk_…" data-target="#blog" defer></script>To let your signed-in users react and comment:
<script>
window.HelpBitesBlog = window.HelpBitesBlog || []
HelpBitesBlog.push(['init', {
getToken: () => fetch('/api/support/token').then(r => r.ok ? r.json() : null).then(r => r && r.token),
onSignIn: () => openYourLoginModal() // a signed-out reader tried to write
}])
// After your user signs in or out:
HelpBitesBlog.push(['refresh'])
</script>Already rendering posts yourself (from the API, for SEO)? Add only what goes under one:
<div id="extras"></div>
<script src="https://YOUR-SUPPORT-HOST/blog.js" data-project="pk_…" data-view="extras" data-post="my-post-slug" data-target="#extras" defer></script>Options#
As data-* attributes or in init:
| Option | |
|---|---|
project |
The public key. Required. |
target |
A selector; without it the blog is drawn where the script tag is. |
view |
blog (the list; the default), post (one post with everything) or extras (reactions, comments and sign-up only). |
post |
The slug, for post and extras. |
tag |
Only posts with this tag. |
q |
A search, as if typed in the search box. |
layout |
list, grid or magazine (the first post across the top); defaults to the hosted blog's. |
body |
With view="extras" under your own copy of a members-only post: a selector the post's HTML goes into once the reader may read it. |
limit |
Posts per page (10). |
theme |
system, light or dark; defaults to a dark/light class on your <html>. |
accent |
A colour; defaults to the project's. |
radius |
Corner radius in px (14). |
font |
inherit (your page's; the default), system, serif or mono. |
show |
Which parts: cover,excerpt,tags,author,date,badges,search,reactions,comments,subscribe. |
deep-link-param |
The URL parameter an open post is kept in (post). |
host |
Where HelpBites is, when you serve blog.js from your own site (it is MIT). |
labels |
init only: any of the words, e.g. { back: 'Back to the blog', post: 'Send' }. |
Calls: HelpBitesBlog.push(['open', 'slug']), (['back']), (['refresh']).
Every reader action is a helpbites:blog event on window, with
detail: { type: 'open' | 'react' | 'comment' | 'subscribe', slug }.
Restyle it#
It is drawn in a shadow root, so your CSS does not leak in by accident. Change it
on purpose with variables on .help-bites-blog:
.help-bites-blog {
--hb-blog-accent: #0f766e;
--hb-blog-on-accent: #fff;
--hb-blog-bg: transparent;
--hb-blog-card: #fff;
--hb-blog-text: #111;
--hb-blog-muted: #666;
--hb-blog-line: #e5e5e5;
--hb-blog-radius: 8px;
--hb-blog-font: inherit;
--hb-blog-width: 720px;
}
.help-bites-blog::part(card) { box-shadow: 0 1px 2px rgb(0 0 0 / 0.06); }Parts: header, search, list, card, cover, title, meta, excerpt,
tag, badge, post, body, gate, reactions, reaction, comments,
comment, composer, button, subscribe.
A members-only post shows its excerpt and a This post is for members box
(the gate part) with the sign-in: your onSignIn, or the email form with its
code. With getToken handing over a signed-in user, it simply opens.
In a Swift app#
A list of posts that opens each one, with its reactions, read with plain
URLSession against the public API. Pass token (your user's identity token,
from your server) and readers can react; leave it out and the view is read-only.
// BlogView.swift. MIT. A HelpBites blog: posts, one post, its reactions.
import SwiftUI
struct BlogPost: Decodable, Identifiable, Hashable {
struct Reaction: Decodable, Hashable { let key: String; let count: Int; let reacted: Bool? }
let id, slug, title, excerpt, publishedAt, authorName: String
let coverImageUrl: String
let readingMinutes: Int
var reactions: [Reaction]
var bodyHtml: String?
}
struct BlogView: View {
let host: URL
let projectKey: String
var token: String? = nil
var accent: Color = .accentColor
var cornerRadius: CGFloat = 12
@State private var posts: [BlogPost] = []
var body: some View {
List(posts) { post in
NavigationLink(value: post) {
VStack(alignment: .leading, spacing: 6) {
if let url = URL(string: post.coverImageUrl), !post.coverImageUrl.isEmpty {
AsyncImage(url: url) { $0.resizable().scaledToFill() } placeholder: { Color.secondary.opacity(0.1) }
.frame(height: 140).clipShape(.rect(cornerRadius: cornerRadius))
}
Text(post.title).font(.headline)
Text(post.excerpt).font(.subheadline).foregroundStyle(.secondary).lineLimit(3)
}
}
}
.navigationTitle("Blog")
.navigationDestination(for: BlogPost.self) { BlogPostView(api: api, slug: $0.slug, accent: accent) }
.task {
struct Page: Decodable { let posts: [BlogPost] }
if let page: Page = try? await api.get("blog") { posts = page.posts }
}
}
private var api: BlogAPI { BlogAPI(host: host, projectKey: projectKey, token: token) }
}
struct BlogPostView: View {
let api: BlogAPI
let slug: String
let accent: Color
@State private var post: BlogPost?
@State private var text = AttributedString()
private let emoji = ["heart": "❤️", "+1": "👍", "hooray": "🎉", "laugh": "😄", "rocket": "🚀", "eyes": "👀"]
var body: some View {
ScrollView {
if let post {
VStack(alignment: .leading, spacing: 12) {
Text(post.title).font(.largeTitle.bold())
Text("\(post.authorName) · \(post.readingMinutes) min read").font(.subheadline).foregroundStyle(.secondary)
Text(text)
HStack {
ForEach(["heart", "+1", "hooray", "laugh", "rocket", "eyes"], id: \.self) { key in
let reaction = post.reactions.first { $0.key == key }
Button { Task { await react(key) } } label: {
Text("\(emoji[key] ?? "")\(reaction.map { " \($0.count)" } ?? "")")
}
.buttonStyle(.bordered).tint(reaction?.reacted == true ? accent : .secondary)
.disabled(api.token == nil)
}
}
}
.padding()
}
}
.task {
struct One: Decodable { let post: BlogPost }
guard let one: One = try? await api.get("blog/\(slug)") else { return }
post = one.post
// The body is sanitised HTML; this keeps its headings, lists and links.
if let data = (one.post.bodyHtml ?? "").data(using: .utf8),
let html = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil),
let rendered = try? AttributedString(html, including: \.uiKit) {
text = rendered
}
}
}
private func react(_ key: String) async {
struct Reactions: Decodable { let reactions: [BlogPost.Reaction] }
if let answer: Reactions = try? await api.post("blog/\(slug)/reactions", body: ["reaction": key]) { post?.reactions = answer.reactions }
}
}
struct BlogAPI {
let host: URL
let projectKey: String
let token: String?
func get<T: Decodable>(_ path: String) async throws -> T {
try await send(URLRequest(url: url(path)))
}
func post<T: Decodable>(_ path: String, body: [String: String]) async throws -> T {
var request = URLRequest(url: url(path))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
return try await send(request)
}
private func url(_ path: String) -> URL {
// Signed in, the token names the project; signed out, the public key does.
var components = URLComponents(url: host.appending(path: "v1/\(path)"), resolvingAgainstBaseURL: false)!
if token == nil { components.queryItems = [URLQueryItem(name: "pk", value: projectKey)] }
return components.url!
}
private func send<T: Decodable>(_ request: URLRequest) async throws -> T {
var request = request
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(T.self, from: data)
}
}Use it inside a NavigationStack: BlogView(host: URL(string: "https://YOUR-SUPPORT-HOST")!, projectKey: "pk_…").
accent and cornerRadius set the colours of the reactions and the shape of the
covers; fonts come from your app's environment. The HTML conversion uses UIKit;
on macOS use \.appKit instead of \.uiKit. It is your file, so change anything:
comments are GET/POST v1/blog/<slug>/comments, below.
Your own UI#
Everything the hosted page and the embed draw is public JSON, open to any origin, for a blog you build yourself (a Nuxt or Next site rendering posts for SEO, an app screen):
# The live posts, newest first (no bodies), and the blog's name, layout and options
curl -s "https://YOUR-SUPPORT-HOST/v1/blog?pk=PROJECT_PUBLIC_KEY&limit=20&tag=engineering"
# A search; the featured posts; the pages
curl -s "https://YOUR-SUPPORT-HOST/v1/blog?pk=PROJECT_PUBLIC_KEY&q=offline"
curl -s "https://YOUR-SUPPORT-HOST/v1/blog?pk=PROJECT_PUBLIC_KEY&featured=true&limit=1"
curl -s "https://YOUR-SUPPORT-HOST/v1/blog?pk=PROJECT_PUBLIC_KEY&type=page"
# One post, with its sanitised HTML: safe to put straight into your page
curl -s "https://YOUR-SUPPORT-HOST/v1/blog/my-post-slug?pk=PROJECT_PUBLIC_KEY"
# Its comments, oldest first (replies have parentId)
curl -s "https://YOUR-SUPPORT-HOST/v1/blog/my-post-slug/comments?pk=PROJECT_PUBLIC_KEY"
# As a signed-in reader (an identity token): react, comment, reply
curl -s -X POST https://YOUR-SUPPORT-HOST/v1/blog/my-post-slug/reactions \
-H "Authorization: Bearer $IDENTITY_TOKEN" -H 'Content-Type: application/json' -d '{"reaction":"heart"}'
curl -s -X POST https://YOUR-SUPPORT-HOST/v1/blog/my-post-slug/comments \
-H "Authorization: Bearer $IDENTITY_TOKEN" -H 'Content-Type: application/json' -d '{"body":"Nice post!"}'A reader's request with a token is never cached; an anonymous one may be cached
for a minute. 404 means the project's blog is off, or the post is a draft or
scheduled. Each post carries type, featured, visibility, locked (a
members-only post this viewer may not read: no bodyHtml), the SEO fields
(metaTitle, metaDescription, ogImageUrl, canonicalUrl, each empty when
the post's own applies) and coverImageAlt.
Managing posts over the API#
With an admin key (ak_…) or an operator session, scoped to the key's
workspace (and projects):
# Write a post (Markdown) and publish it now; tags and a cover are optional
curl -s -X POST https://YOUR-SUPPORT-HOST/v1/admin/blog/posts \
-H "Authorization: Bearer $HELPBITES_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"project":"PROJECT_ID","title":"Release 3.0","body":"## What is new\n\nA lot.","tags":["Releases"],"status":"published"}'
# Schedule a draft for tomorrow morning
curl -s -X PATCH https://YOUR-SUPPORT-HOST/v1/admin/blog/posts/POST_ID \
-H "Authorization: Bearer $HELPBITES_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"status":"published","publishedAt":"2026-11-02T08:00:00Z"}'
# An About page (pages stay out of the list and the feed)
curl -s -X POST https://YOUR-SUPPORT-HOST/v1/admin/blog/posts \
-H "Authorization: Bearer $HELPBITES_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"project":"PROJECT_ID","type":"page","title":"About","slug":"about","body":"We are three people.","status":"published"}'
# Members only, featured, with its own search title
curl -s -X PATCH https://YOUR-SUPPORT-HOST/v1/admin/blog/posts/POST_ID \
-H "Authorization: Bearer $HELPBITES_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"visibility":"members","featured":true,"metaTitle":"Release 3.0: what is new"}'
# Upload an image for the body or the cover
curl -s -X POST https://YOUR-SUPPORT-HOST/v1/admin/blog/images \
-H "Authorization: Bearer $HELPBITES_ADMIN_KEY" -F project=PROJECT_ID -F file=@shot.png
# Email it to the "blog" topic's subscribers now (a plan with campaigns)
curl -s -X POST https://YOUR-SUPPORT-HOST/v1/admin/blog/posts/POST_ID/newsletter \
-H "Authorization: Bearer $HELPBITES_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"send":"now","audience":{"topic":"blog"}}'
# Every comment, for moderation; hide one
curl -s "https://YOUR-SUPPORT-HOST/v1/admin/blog/comments?project=PROJECT_ID" -H "Authorization: Bearer $HELPBITES_ADMIN_KEY"
curl -s -X PATCH https://YOUR-SUPPORT-HOST/v1/blog/comments/COMMENT_ID \
-H "Authorization: Bearer $HELPBITES_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"status":"hidden"}'The settings are PATCH /v1/admin/projects/{id} with { blog: { … } } (admins;
only the keys sent change): enabled, title, description, comments,
reactions, commentNotify, postUrl, newsletterTopic, subscribeForm and
design (layout, font, colorScheme, accent, logoUrl, coverImageUrl,
navigation, social, footer; partial too, and social merges per network,
"" removing one). The whole reference is in the OpenAPI document at
/_docs/scalar, under Blog.
# The magazine layout, serif, with an About link
curl -s -X PATCH https://YOUR-SUPPORT-HOST/v1/admin/projects/PROJECT_ID \
-H "Authorization: Bearer $HELPBITES_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"blog":{"design":{"layout":"magazine","font":"serif","navigation":[{"label":"About","url":"/about"}]}}}'Preview over the API#
POST /v1/admin/blog/preview is what the editor's Preview and Live preview
call. Send the fields of a post (title, body, bodyFormat, excerpt,
coverImageUrl, tags, authorName, publishedAt, type) and get { html }:
the whole page in the blog's design, as readers will see it. Nothing is read from
or saved to a stored post, so it works for a draft that was never saved; reactions,
comments and the sign-up form are left out. Show it in an iframe with sandbox=""
(srcdoc); a Live preview is that call repeated as you type.
curl -X POST https://your-helpbites.example/v1/admin/blog/preview \
-H "Authorization: Bearer ak_…" -H "Content-Type: application/json" \
-d '{ "project": "PROJECT_ID", "title": "Offline mode", "body": "It **works** on a plane." }'Write with AI over the API#
POST /v1/admin/blog/ai is what the editor's button calls. Nothing is saved: you
get a proposal and send what you keep to the create or update endpoint above.
curl -X POST https://your-helpbites.example/v1/admin/blog/ai \
-H "Authorization: Bearer ak_…" -H "Content-Type: application/json" \
-d '{ "project": "PROJECT_ID", "mode": "write",
"instruction": "Announce our new offline mode, for a technical audience." }'
# { "result": { "title": "…", "excerpt": "…", "metaDescription": "…", "tags": ["…"],
# "body": "## …", "notes": "…", "repos": [{ "repo": "you/app", "read": true }] } }mode:write(needsinstructionortitle),continue,improveandmeta(these needbody, the Markdown so far) andinsert(needsinstruction: a passage for the cursor).continueandinsertreturn only the text to add. Sendselection(Markdown) withimproveto rewrite only that passage;bodyis then its replacement andinstructionsays how.repos:owner/nameof linked repos to read; all of the project's when left out.GET /v1/admin/cms/repos?project=…lists them.- Fields a mode does not produce are empty. Errors:
400nothing to go on,501no AI provider configured,502the provider failed,429six a minute per operator.
Webhooks#
With the project's event webhook set (Email → Settings; see MARKETING.md), the blog sends, signed and retried like every event:
| Event | When |
|---|---|
blog.post.published |
A post went public: published now, or its scheduled moment came. |
blog.post.updated |
A public post was changed. |
blog.post.unpublished |
A public post stopped being public: unpublished, or rescheduled into the future. |
blog.post.deleted |
A public post was deleted. |
data is { post: { id, slug, title, visibility, publishedAt, url } }. Rebuild
a static site, post to social media, or ping a search engine on them. Pages and
imports send nothing.
Moving from Ghost#
Project → Blog → … → Import from Ghost takes the file Ghost makes under
Settings → Advanced → Import/Export → Export content (a .json):
- Posts and pages, with their HTML (kept as HTML), slug, excerpt, cover and its
alt text, tags (Ghost's internal
#tagsleft out), first author, publish date, featured flag, meta title and description, social image and canonical URL. - Published posts stay published with their dates, scheduled ones stay scheduled,
drafts stay drafts. Email-only posts (
sent) come in as drafts. - Visibility:
publicstays public;membersis members-only;paidandtiersbecome members-only too (there are no paid tiers here), and the import says how many. - Your Ghost site's address: the export writes its own links and images as
__GHOST_URL__/…; give the address and they point there again. The images stay on that site, so keep it up until you have moved them (upload them here and change the posts), or the posts will show broken images. - Importing the same file again skips what came in before (matched by Ghost's id); Overwrite posts imported before replaces them with the file's copy. Nothing is emailed and no webhook fires. Up to 2,000 posts per file.
- Members are a separate CSV in Ghost (Members → Export). Import it under
Contacts → Import:
email,name,labels(as tags) andsubscribed_to_emails(as newsletter consent) are picked up; unset theidcolumn, which is Ghost's, not your app's.
Over the API:
curl -s -X POST https://YOUR-SUPPORT-HOST/v1/admin/blog/import \
-H "Authorization: Bearer $HELPBITES_ADMIN_KEY" -H 'Content-Type: application/json' \
-d "{\"project\":\"PROJECT_ID\",\"siteUrl\":\"https://blog.example.com\",\"export\":$(cat my-blog.ghost.json)}"
# → { "created": 41, "updated": 0, "skipped": 0, "warnings": ["2 paid or tier-only posts are members-only here: …"] }Limits#
| Title | 200 characters |
| Body | 200,000 characters (Markdown or HTML) |
data |
200,000 characters as JSON |
| Search and social | title 300, description 500, alt text 300 characters |
| Navigation | 8 links |
| Ghost import | 2,000 posts per file |
| Excerpt | 300 characters |
| Write with AI | 2,000 characters of instruction, 3 repos, 6 requests a minute per operator |
| Tags | 10 per post, 40 characters each |
| Images | 8 MB, PNG, JPEG, GIF or WebP |
| Comments | 4,000 characters, 6 links, five per ten minutes per reader |
| Reactions | 60 a minute per reader |
| Public reads | 1,200 a minute per IP |
| Feed | the latest 30 posts |