Building a help center
Render your own help center from the Public API — categories, paginated and searchable articles, and clean slug URLs.
The Public API exposes your published help-center content as JSON, so you can render it inside your own site rather than sending customers to a separate hosted page. Three endpoints, one publishable key, no visitor identity involved.
What's visible, and what isn't
Only published, public articles are ever returned
Every article in these responses is both published and visibility = public.
Drafts and internal articles are invisible here — there's no parameter that exposes
them, and no way to tell from the API that they exist.
So an article your team can see in the dashboard but a customer can't will simply be absent. When someone reports "the article isn't showing up," check its status and visibility before you check your code.
The same rule shapes the category list: only categories holding at least one published, public article are returned. An empty category, or one whose articles are all drafts, doesn't appear at all. That's usually a feature — the nav you build can't contain a link to an empty page — but it does mean the categories you get back are a subset of the ones your team sees.
1. Fetch the categories
GET /api/v1/public/categories returns
the whole list in one call. It isn't paginated and carries no meta.
const res = await fetch(`${BASE_URL}/api/v1/public/categories`, {
headers: { Authorization: `Bearer ${process.env.ASSISTIVE_PUBLISHABLE_KEY}` },
});
const { data } = await res.json();
// position is the order the team arranged them in — respect it.
const nav = [...data].sort((a, b) => a.position - b.position);Each category is {id, name, slug, position}. Sort by position. It's the display
order set in the dashboard, and it's the only field carrying the team's intent — they put
"Getting started" first on purpose. Alphabetical order throws that away.
Use slug for your own URLs, and id when you filter articles by category.
2. List and paginate the articles
GET /api/v1/public/articles returns a
page of articles, newest first by published_at.
| Parameter | Default | Notes |
|---|---|---|
page | 1 | Anything below 1 is coerced to 1. |
per_page | 20 | Anything below 1 falls back to 20; anything above 100 is capped at 100. |
category_id | — | Filter to one category. An unparseable value is ignored, not an error. |
search | — | Case-insensitive substring match against title or body. |
Every article comes back with its full body
There's no summary or excerpt field. The complete body is included for every
article in the list — which makes this heavier than a typical index endpoint.
If you're rendering a directory of titles, ask for a modest per_page rather than
pulling 100 full articles to show 100 headlines. Cache the result; help-center content
changes far less often than you'll render it.
Walk the pages with meta.total_pages:
async function allArticles() {
const articles = [];
let page = 1;
let totalPages = 1;
do {
const res = await fetch(
`${BASE_URL}/api/v1/public/articles?page=${page}&per_page=20`,
{ headers: { Authorization: `Bearer ${process.env.ASSISTIVE_PUBLISHABLE_KEY}` } },
);
const { data, meta } = await res.json();
articles.push(...data);
totalPages = meta.total_pages;
page += 1;
} while (page <= totalPages);
return articles;
}meta is present on this endpoint and nowhere else in the Public API, so this is the one
place a pagination loop is called for.
One quiet gotcha: a malformed category_id is ignored rather than rejected. You get a
200 and the unfiltered page back. If a category filter appears to do nothing, check
you're sending a well-formed UUID — the API won't tell you that you aren't.
3. Search
search matches a case-insensitive substring against both the title and the body, which
makes it useful as a genuine content search rather than just a title filter.
const params = new URLSearchParams({ search: query, per_page: '10' });
const res = await fetch(`${BASE_URL}/api/v1/public/articles?${params}`, {
headers: { Authorization: `Bearer ${process.env.ASSISTIVE_PUBLISHABLE_KEY}` },
});
const { data, meta } = await res.json();It's substring matching, not a ranked search engine — no stemming, no fuzzy matching, no relevance ordering. Results still come back newest-first. For a small help center that's usually plenty; for a large one, consider pulling the articles and indexing them yourself.
4. Render one article by slug
GET /api/v1/public/articles/{ref} takes
either a UUID or a slug — the server tries to parse ref as a UUID and falls back to
a slug lookup.
That's what lets you build readable URLs and pass the last segment straight through:
// Your route: /help/:slug → /help/how-to-reset-your-password
export async function loader({ params }) {
const res = await fetch(
`${BASE_URL}/api/v1/public/articles/${encodeURIComponent(params.slug)}`,
{ headers: { Authorization: `Bearer ${process.env.ASSISTIVE_PUBLISHABLE_KEY}` } },
);
if (res.status === 404) throw notFound();
const { data } = await res.json();
return data;
}No slug-to-UUID map to keep in sync on your side.
A 404 here (article not found) collapses three cases into one: the article doesn't
exist, it exists but isn't published, or it's published but internal. You can't tell them
apart, and shouldn't need to — all three mean this customer may not read this, so all
three should render the same "not found" page.
Putting it together
A minimal help center is three routes:
| Your route | Call | Notes |
|---|---|---|
/help | GET /categories + GET /articles?per_page=10 | Category nav sorted by position, plus recent articles. |
/help/c/:categorySlug | GET /articles?category_id=… | Look the ID up from the category list you already have. |
/help/:slug | GET /articles/:slug | Pass the slug straight through. |
Two things worth doing on top:
- Cache. These responses change rarely. Caching them for minutes — or rebuilding a static help center on a schedule — keeps you well clear of the publishable key's organization-wide rate limit, which is shared by every other integration you run. See rate limits.
- Render
bodyas Markdown. It comes back as source text, not HTML, so it's yours to render — with whatever sanitizer and typography your site already uses.