Read the public knowledge base
Fetch the knowledge base's home page, articles, categories, journeys, tags, authors and search results, send a thumbs up or down or a suggested edit, and read as a signed-in reader.
Updated
JourneyAPI: content and public APIsstep 1 of 3
Next: Read the public blogOn this page
The knowledge base is a set of articles that teach, shelved into categories and arranged into journeys, which are ordered paths through them. This article shows the read API behind the public site: you can build the home page, list and filter articles, open one by its address, search, record whether an article helped, and tell the editors when one is wrong. You need no account and no key.
Every route here sits under /api/v1/kb/site. It answers only for articles that are published on the public web, so a draft or an internal article is simply not found. Responses are marked as cacheable for a minute, so an article an editor withdraws is gone within that time. The same shapes are served under /api/v1/kb/read for signed-in readers, who see what their account may read: see Read as a signed-in reader.
The home page
/api/v1/kb/site/home- Auth
- none
One call fills a landing page. It answers an object with categories, journeys, featured, popular, recent, tags and technologies: the shelves with their article counts, the journeys, three lists of up to six article cards each, and the twenty most used tags and technologies.
curl -s "https://solnyxus.com/api/v1/kb/site/home"
const res = await fetch("https://solnyxus.com/api/v1/kb/site/home");
const home = await res.json();
console.log(home.featured.map((a) => a.title));
import requests
res = requests.get("https://solnyxus.com/api/v1/kb/site/home", timeout=30)
for a in res.json()["featured"]:
print(a["title"])
package main
import (
"encoding/json"
"fmt"
"net/http"
)
func main() {
res, err := http.Get("https://solnyxus.com/api/v1/kb/site/home")
if err != nil {
panic(err)
}
defer res.Body.Close()
var home struct {
Featured []struct{ Title string }
}
json.NewDecoder(res.Body).Decode(&home)
for _, a := range home.Featured {
fmt.Println(a.Title)
}
}
List and filter articles
/api/v1/kb/site/articles- Auth
- none
Returns one page of article cards with the total, so you can draw paging. Leave every filter out to list everything. Filters combine: an article must match all of the ones you send. A category, journey, tag, technology or author that does not exist gives an empty list, not an error.
Parameters (query string)
| Name | Type | Required | Description |
|---|---|---|---|
category | string | No | A category key such as getting-started; a parent category includes its children. |
journey | string | No | A journey slug; the articles on that path. |
tag | string | No | One tag, for example tickets. |
tech | string | No | One technology key, for example rest. |
author | string | No | An author's handle. Only authors who have switched their page on can be found. |
kind | string | No | guide, howto, tutorial, concept, reference, api, troubleshooting or release. Another value is 400. |
level | string | No | beginner, intermediate or advanced. Another value is 400. |
discipline | string | No | A discipline key, such as security or networking, shared with the blog. An unknown key is 400. |
q | string | No | Words to look for in the title, summary, tags, technologies and text. Best matches come first. More than 200 characters is 400. On the first page, the search is counted for the editors (see Search). |
instant | string | No | 1 when you ask as the reader types. The answer is the same, but the search is not counted. |
sort | string | No | title, popular, updated or recent. Left out, articles come in the order the editors set, or best match first when you search. A journey filter always keeps the journey's order. |
page | integer | No | The page, starting at 1. |
pageSize | integer | No | Cards per page. The default is 20 and the most is 100. |
curl -s "https://solnyxus.com/api/v1/kb/site/articles?kind=api&level=beginner&page=1"
const params = new URLSearchParams({ kind: "api", level: "beginner", page: "1" });
const res = await fetch(`https://solnyxus.com/api/v1/kb/site/articles?${params}`);
const list = await res.json();
console.log(list.total, "articles;", list.items.map((a) => a.slug));
import requests
res = requests.get(
"https://solnyxus.com/api/v1/kb/site/articles",
params={"kind": "api", "level": "beginner", "page": 1},
timeout=30,
)
data = res.json()
print(data["total"], "articles;", [a["slug"] for a in data["items"]])
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
func main() {
q := url.Values{"kind": {"api"}, "level": {"beginner"}, "page": {"1"}}
res, err := http.Get("https://solnyxus.com/api/v1/kb/site/articles?" + q.Encode())
if err != nil {
panic(err)
}
defer res.Body.Close()
var list struct {
Total int
Items []struct{ Slug string }
}
json.NewDecoder(res.Body).Decode(&list)
fmt.Println(list.Total, "articles")
for _, a := range list.Items {
fmt.Println(a.Slug)
}
}
A card is the short form of an article. The response below is trimmed to one card.
{
"items": [
{
"slug": "your-first-api-call",
"title": "Make your first API call",
"summary": "Sign in, send a request and read the answer.",
"kind": "api",
"level": "beginner",
"category": { "key": "api", "title": "API reference" },
"tags": ["api", "getting-started"],
"readingMinutes": 4,
"updatedAt": "2026-10-08T09:30:00Z",
"publishedAt": "2026-10-01T08:00:00Z"
}
],
"total": 1,
"page": 1,
"pageSize": 20
}
Read one article
/api/v1/kb/site/articles/{slug}- Auth
- none
{slug} is the last part of an article's address. The answer holds article, the full text and details, together with journeys (the paths it sits on), prerequisites (read these first), related (go deeper) and posts, blog posts that point to it or share its tags. prerequisites and related are lists of cards.
The answer also has two lists that are always empty on the public site, because they are shown to signed-in readers only. apps names the software the article is about. products is [{key, name}], the products we sell that the article is for, such as {"key": "alpha", "name": "Alpha"}. When it is not empty, only clients who subscribe to at least one of those products can read the article, and the list tells a reader why it is theirs.
The article is a card plus body (markdown), outcomes, technologies (keys), discipline ({key, title}), lastReviewedAt, the counters helpful, notHelpful and views, and author. author is null unless the writer has put a public page on the blog; then it holds name, handle, jobTitle, bio, location and avatarUrl, a path you can fetch.
Each entry of journeys describes one path: slug, title, summary, kind, level, this article's position, the total number of steps, the steps (slug, title, readingMinutes and current on this one), and prev and next, the steps either side, or null.
curl -s "https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call"
const res = await fetch("https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call");
if (res.status === 404) throw new Error("no such article");
const { article, prerequisites } = await res.json();
console.log(article.title, "needs", prerequisites.length, "other articles first");
import requests
res = requests.get(
"https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call",
timeout=30,
)
res.raise_for_status()
data = res.json()
print(data["article"]["title"], "needs", len(data["prerequisites"]), "other articles first")
package main
import (
"encoding/json"
"fmt"
"net/http"
)
func main() {
res, err := http.Get("https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call")
if err != nil {
panic(err)
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
panic("no such article")
}
var data struct {
Article struct{ Title string }
Prerequisites []struct{ Slug string }
}
json.NewDecoder(res.Body).Decode(&data)
fmt.Println(data.Article.Title, len(data.Prerequisites))
}
The response is trimmed to the useful fields.
{
"article": {
"slug": "your-first-api-call",
"title": "Make your first API call",
"summary": "Sign in, send a request and read the answer.",
"kind": "api",
"level": "beginner",
"category": { "key": "api", "title": "API reference" },
"tags": ["api", "getting-started"],
"readingMinutes": 4,
"updatedAt": "2026-10-08T09:30:00Z",
"body": "Sign in first, then send the request.",
"outcomes": ["Send a request and read the answer"],
"technologies": ["rest"],
"helpful": 12,
"notHelpful": 1,
"views": 340,
"author": null
},
"journeys": [
{
"slug": "api-quickstart",
"title": "Build with the API",
"kind": "tutorial",
"position": 1,
"total": 3,
"steps": [
{ "slug": "your-first-api-call", "title": "Make your first API call", "current": true },
{ "slug": "create-a-ticket", "title": "Create a ticket" }
],
"prev": null,
"next": { "slug": "create-a-ticket", "title": "Create a ticket" }
}
],
"prerequisites": [],
"related": [],
"apps": [],
"products": []
}
An address that does not exist, or that is not on the public web, is a 404 with article not found.
Shelves, journeys, tags and authors
These routes list the structure around the articles. Only the two category and journey detail routes take paging parameters.
| Route | Answer |
|---|---|
GET /api/v1/kb/site/categories | The top-level categories, each with key, title, summary, icon, count (articles, children included) and children. A category with nothing on it is left out. |
GET /api/v1/kb/site/categories/{key} | {category, parent, items, total, page, pageSize}: one category and a page of its articles. page and pageSize work as above. 404 with category not found. |
GET /api/v1/kb/site/journeys | The journeys, each with slug, title, summary, kind, level, steps (how many) and minutes (reading time). |
GET /api/v1/kb/site/journeys/{slug} | {journey, steps}: the journey and its articles in order, each a card with its position. 404 with journey not found. |
GET /api/v1/kb/site/tags | [{key, label, count}], every tag in use, most used first. |
GET /api/v1/kb/site/technologies | [{key, label, count}], every technology in use, with a readable label such as PostgreSQL. |
GET /api/v1/kb/site/authors/{handle} | {author, items}: a writer who has put a public page on the blog, with bio, links and disciplines, and their articles. Anyone else is 404 with author not found. |
curl -s "https://solnyxus.com/api/v1/kb/site/tags"
const res = await fetch("https://solnyxus.com/api/v1/kb/site/tags");
for (const t of await res.json()) console.log(t.key, t.count);
import requests
res = requests.get("https://solnyxus.com/api/v1/kb/site/tags", timeout=30)
for t in res.json():
print(t["key"], t["count"])
package main
import (
"encoding/json"
"fmt"
"net/http"
)
func main() {
res, err := http.Get("https://solnyxus.com/api/v1/kb/site/tags")
if err != nil {
panic(err)
}
defer res.Body.Close()
var tags []struct {
Key string
Count int
}
json.NewDecoder(res.Body).Decode(&tags)
for _, t := range tags {
fmt.Println(t.Key, t.Count)
}
}
[
{ "key": "api", "label": "api", "count": 18 },
{ "key": "tickets", "label": "tickets", "count": 7 }
]
Search
/api/v1/kb/site/search- Auth
- none
Searches the knowledge base and the blog together. Pass your words in q, and optionally limit, the most results of each kind (default 8, at most 20). The answer is {articles, posts}, best match first: article cards, and blog posts as the blog read API shapes them. An empty q gives two empty lists; more than 200 characters is 400.
Searches are counted so the editors can see what readers look for and do not find: this route, and the article list when it has q on its first page. What is stored is the day, which audience searched (web, customers or staff), the words and how many articles they found. The words are normalised first: lower case, spaces collapsed, an email address replaced by [email] and a number of six digits or more by [number], cut at 100 characters. Nothing about who searched is kept, no account and no address, and the counts are deleted after 180 days. A search box that asks as the reader types should add instant=1, which is answered the same and not counted.
curl -s "https://solnyxus.com/api/v1/kb/site/search?q=first+ticket"
const res = await fetch("https://solnyxus.com/api/v1/kb/site/search?q=" + encodeURIComponent("first ticket"));
const { articles, posts } = await res.json();
console.log(articles.length, "articles,", posts.length, "posts");
import requests
res = requests.get(
"https://solnyxus.com/api/v1/kb/site/search",
params={"q": "first ticket"},
timeout=30,
)
data = res.json()
print(len(data["articles"]), "articles,", len(data["posts"]), "posts")
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
func main() {
res, err := http.Get("https://solnyxus.com/api/v1/kb/site/search?q=" + url.QueryEscape("first ticket"))
if err != nil {
panic(err)
}
defer res.Body.Close()
var out struct {
Articles []struct{ Slug string }
Posts []struct{ Slug string }
}
json.NewDecoder(res.Body).Decode(&out)
fmt.Println(len(out.Articles), "articles,", len(out.Posts), "posts")
}
Say whether an article helped
/api/v1/kb/site/articles/{slug}/feedback- Auth
- none
Records the thumbs at the foot of an article and answers the new totals, {"helpful": 13, "notHelpful": 1}. The body is {"helpful": true} or {"helpful": false}. Votes are limited per address and article: a dozen in a row, then one every ten seconds.
| Name | Type | Required | Description |
|---|---|---|---|
helpful | boolean | Required | true for a thumbs up, false for a thumbs down. |
curl -s -X POST "https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call/feedback" \
-H "Content-Type: application/json" -d '{"helpful":true}'
const res = await fetch(
"https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call/feedback",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ helpful: true }),
},
);
const totals = await res.json();
console.log(res.status, totals.helpful, totals.notHelpful);
import requests
res = requests.post(
"https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call/feedback",
json={"helpful": True},
timeout=30,
)
totals = res.json()
print(res.status_code, totals["helpful"], totals["notHelpful"])
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
func main() {
url := "https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call/feedback"
res, err := http.Post(url, "application/json", strings.NewReader(`{"helpful":true}`))
if err != nil {
panic(err)
}
defer res.Body.Close()
var totals struct{ Helpful, NotHelpful int }
json.NewDecoder(res.Body).Decode(&totals)
fmt.Println(res.StatusCode, totals.Helpful, totals.NotHelpful)
}
{ "helpful": 13, "notHelpful": 1 }
| Status | When |
|---|---|
400 | The body does not say helpful. |
404 | The article does not exist or is not on the public web. |
429 | Too many votes on this article. Wait for the Retry-After header. |
Suggest an edit
/api/v1/kb/site/articles/{slug}/suggestions- Auth
- none
- Rate limit
- five per address, then one every 10 minutes
Sends the editors a correction from an article's foot: what is wrong and, if the reader has it, the text they would put instead. It lands in the editors' queue and is never published. Anyone who may read the article may suggest an edit to it. The answer is 201 with {"status": "received"}.
| Name | Type | Required | Description |
|---|---|---|---|
problem | string | Required | What is wrong, 3 to 2,000 characters. |
proposal | string | No | The text the reader would put instead, up to 10,000 characters. |
email | string | No | An address to answer the reader at, 254 characters at most. Send a bare address, such as you@example.com. Only a reader who is not signed in is asked: for a signed-in reader the account is recorded and email is ignored. |
The text is stored as typed, with control characters removed, and the editors only ever see it as plain text.
curl -s -X POST "https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call/suggestions" \
-H "Content-Type: application/json" \
-d '{"problem":"Step 2 names a button the page no longer has.","proposal":"Select Send, then read the answer.","email":"you@example.com"}'
const res = await fetch(
"https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call/suggestions",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
problem: "Step 2 names a button the page no longer has.",
proposal: "Select Send, then read the answer.",
email: "you@example.com",
}),
},
);
if (res.status === 429) console.log("try again in", res.headers.get("Retry-After"), "seconds");
else console.log(res.status, (await res.json()).status);
import requests
res = requests.post(
"https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call/suggestions",
json={
"problem": "Step 2 names a button the page no longer has.",
"proposal": "Select Send, then read the answer.",
"email": "you@example.com",
},
timeout=30,
)
if res.status_code == 429:
print("try again in", res.headers["Retry-After"], "seconds")
else:
print(res.status_code, res.json()["status"])
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
func main() {
url := "https://solnyxus.com/api/v1/kb/site/articles/your-first-api-call/suggestions"
body := `{"problem":"Step 2 names a button the page no longer has.","proposal":"Select Send, then read the answer.","email":"you@example.com"}`
res, err := http.Post(url, "application/json", strings.NewReader(body))
if err != nil {
panic(err)
}
defer res.Body.Close()
if res.StatusCode == http.StatusTooManyRequests {
fmt.Println("try again in", res.Header.Get("Retry-After"), "seconds")
return
}
var out struct{ Status string }
json.NewDecoder(res.Body).Decode(&out)
fmt.Println(res.StatusCode, out.Status)
}
{ "status": "received" }
| Status | When |
|---|---|
400 | problem is missing or shorter than 3 characters (say what is wrong with the article), a field is over its limit, or that email address does not look right. |
404 | The article does not exist, or the caller may not read it. |
429 | This address has sent its five; one more is allowed every 10 minutes, whichever article it is about. Wait for the Retry-After header (seconds). |
Read as a signed-in reader
Every route above is also served under /api/v1/kb/read, with the same shapes. There the caller's session decides what may be read: send the session cookie, or Authorization: Bearer $TOKEN with your own token. A caller with no session, or an expired one, reads the public web and gets no error. A signed-in client also reads the articles for customers, and staff with kb.read read everything. Answers are private (Cache-Control: private, no-cache, Vary: Cookie, Authorization).
Within the customers level, an article about a product we sell is for that product's subscribers. A client whose organisation subscribes to none of the products it is for gets 404 on its address and never sees it in a list, a search, a journey or a count. The article answer's products names those products.
Two routes exist only under /api/v1/kb/read.
/api/v1/kb/read/me- Auth
- optional session cookie or bearer token
Says who the knowledge base is being read as, so a page can show "Signed in as". The answer is never cached: {"signedIn": false} for a stranger, or {signedIn, via, reader}, where via is session for an ordinary session and kb for the reading token the knowledge base's own site holds, and reader is {name, client} (client is the organisation's name, empty for staff).
curl -s "https://solnyxus.com/api/v1/kb/read/me" -H "Authorization: Bearer $TOKEN"
import { env } from "node:process";
const res = await fetch("https://solnyxus.com/api/v1/kb/read/me", {
headers: { Authorization: `Bearer ${env.TOKEN}` },
});
const me = await res.json();
console.log(me.signedIn ? `${me.reader.name} (${me.via})` : "reading as the public web");
import os
import requests
res = requests.get(
"https://solnyxus.com/api/v1/kb/read/me",
headers={"Authorization": f"Bearer {os.environ['TOKEN']}"},
timeout=30,
)
me = res.json()
print(me["reader"]["name"] if me["signedIn"] else "reading as the public web")
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
req, _ := http.NewRequest("GET", "https://solnyxus.com/api/v1/kb/read/me", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var me struct {
SignedIn bool
Via string
Reader struct{ Name, Client string }
}
json.NewDecoder(res.Body).Decode(&me)
fmt.Println(me.SignedIn, me.Via, me.Reader.Name, me.Reader.Client)
}
{ "signedIn": true, "via": "session", "reader": { "name": "Alex Example", "client": "Example Ltd" } }
/api/v1/kb/read/media/{id}- Auth
- optional session cookie or bearer token
Serves an image an article uses, the media:<id> in its body, by the article's own rule. An image used by an article on the public web is served to anyone and may be cached. One used only by articles for customers is served to a reader who may open one of those articles, and to staff. Anyone else gets 404. The public site's door for the same images is GET /api/v1/kb/media/{id}.
Where next
- Read the blog the same way with Read the public blog.
- Add a chat window to your own page with Embed the live-chat widget.
- If a call returns
400or404, read theerrormessage: it names the parameter or the missing article.