Publish help content with the knowledge base API
Sync articles and categories from a CMS or docs repo into a Kustomer knowledge base, publish new versions and translations, and search published articles from your own site.
This guide publishes help content you write somewhere else, such as a headless CMS, a docs repo in Markdown, or a product database, into a Kustomer knowledge base. By the end you'll have:
- categories created once and remembered by your own ID;
- articles created, updated and published from your source, skipping ones that haven't changed;
- translations published per language, with the others flagged as out of date when the source changes;
- article suggestions on your own contact page, using the public search endpoint with no API key.
Diagram source
flowchart LR S[(Your CMS<br/>or docs repo)] --> J[Sync job] J -- "POST category, once" --> C[Categories] J -- "POST article → PUT draft + publishedAt" --> A[Articles] J -- "POST version → PUT draft + publishedAt" --> A A --> H[Help center, chat widget,<br/>agent desktop] P[Your contact page] -- "GET /p/v3/kb/articles/search" --> A
You'll need an API key with org.permission.kb.read, org.permission.kb.create and org.permission.kb.update, or the broader org.admin.kb.read and org.admin.kb.write. The public search in step 6 needs no key. The code uses the kfetch helper from Making requests.
How articles are stored
An article is a container. Its title, htmlBody, slug and SEO fields live on versions, with a separate series for each language. Each language has at most one draft and one published version, and older live versions become unpublished. The article itself holds what every language shares: categories, tags, scope (public or internal) and your externalId.
So publishing always goes the same way: get a draft in the language, then send publishedAt to that draft.
1. Keep a map of your IDs
The sync needs to know which Kustomer article and category belong to each item in your source. Set externalId to your ID on everything you create; it must be unique in the org, so a retry after a timeout fails instead of creating a duplicate. Also store the Kustomer ID and a hash of what you last published, in your database or a file committed next to the content:
interface Synced {
kustomerId: string
categories?: string[] // Kustomer category IDs last sent, sorted
hashes: Record<string, string> // lang → hash of title, body and summary last published
}
type SyncState = Record<string, Synced> // your ID → what Kustomer hasIf you lose the state, rebuild it by paging through GET /v1/kb/articles and matching attributes.externalId. The hashes then start empty, so the next run republishes everything once. The same applies when a create fails with 409 duplicate: the externalId is already used, usually by an earlier run whose response was lost, so find that article in the list and save its ID instead of creating another. That list doesn't show empty categories, so for a category, copy its ID from the knowledge base editor in Kustomer.
2. Create categories
Categories are the sections of the help center. Every knowledge base has a hidden root category; categories created without parent go under the root of the org's default knowledge base. Text is per language in langs:
/v1/kb/categories
async function ensureCategory(state: SyncState, cat: { id: string; title: Record<string, string>; parentId?: string }) {
if (state[cat.id]) return state[cat.id].kustomerId
const langs = Object.fromEntries(Object.entries(cat.title).map(([lang, title]) => [lang, { title }]))
const parent = cat.parentId ? state[cat.parentId]?.kustomerId : undefined
const res = await kfetch('/v1/kb/categories', {
method: 'POST',
body: JSON.stringify({ externalId: cat.id, langs, ...(parent && { parent }) }),
})
if (!res.ok) throw new Error(`category ${cat.id}: ${res.status} ${await res.text()}`)
const { data } = await res.json()
state[cat.id] = { kustomerId: data.id, hashes: {} }
return data.id
}Create parents before children. Keep the IDs you get back: called with an API key, GET /v1/kb/categories only lists categories that already hold a published public article, so a new, empty category won't appear there. To put a category in a knowledge base other than the default, set parent to that knowledge base's root category.
3. Create or update articles
For a new article, create it, then fill in and publish the draft Kustomer made for it in one request. For an existing one, start a new draft in the language and publish that. The live version stays up until the new one is published. Categories belong to the article rather than a version, so when they change the sync updates the article itself.
post/v1/kb/articles
put/v1/kb/articles/{id}
post/v1/kb/articles/{id}/versions/{lang}
put/v1/kb/articles/{id}/versions/{lang}/draft
import { createHash } from 'node:crypto'
interface SourceArticle {
id: string // the same for an article and its translations
categoryIds: string[]
lang: string // such as en_us
isTranslation?: boolean
title: string
html: string
summary?: string
}
const hashOf = (a: SourceArticle) => createHash('sha256').update(JSON.stringify([a.title, a.html, a.summary ?? ''])).digest('hex')
async function publishArticle(state: SyncState, a: SourceArticle) {
const categories = a.categoryIds.map((c) => state[c].kustomerId).sort()
const known = state[a.id]
if (!known) {
const res = await kfetch('/v1/kb/articles', {
method: 'POST',
body: JSON.stringify({ title: a.title, categories: categories.map((id) => ({ id })), externalId: a.id, source: a.lang }),
})
if (!res.ok) throw new Error(`article ${a.id}: ${res.status} ${await res.text()}`)
// Save the ID before publishing: a retry that POSTs the same externalId again gets 409 duplicate.
state[a.id] = { kustomerId: (await res.json()).data.id, categories, hashes: {} }
} else if (known.categories?.join() !== categories.join()) {
const res = await kfetch(`/v1/kb/articles/${known.kustomerId}`, {
method: 'PUT',
body: JSON.stringify({ categories: categories.map((id) => ({ id })) }),
})
if (!res.ok) throw new Error(`categories ${a.id}: ${res.status} ${await res.text()}`)
known.categories = categories
}
const synced = state[a.id]
const hash = hashOf(a)
if (synced.hashes[a.lang] === hash) return // unchanged since the last publish
if (known) {
// A new draft in this language; it replaces any draft someone left in the editor.
const res = await kfetch(`/v1/kb/articles/${synced.kustomerId}/versions/${a.lang}`, {
method: 'POST',
body: JSON.stringify({ title: a.title, htmlBody: a.html }),
})
if (!res.ok) throw new Error(`version ${a.id} ${a.lang}: ${res.status} ${await res.text()}`)
}
const published = await kfetch(`/v1/kb/articles/${synced.kustomerId}/versions/${a.lang}/draft`, {
method: 'PUT',
body: JSON.stringify({
title: a.title,
htmlBody: a.html,
metaDescription: a.summary,
publishedAt: new Date().toISOString(),
markOthersOutOfDate: Boolean(synced.hashes[a.lang]) && !a.isTranslation, // the source changed, so translations need a look
}),
})
if (!published.ok) throw new Error(`publish ${a.id}: ${published.status} ${await published.text()}`)
synced.hashes[a.lang] = hash
}Without publishedAt, the draft is saved but stays hidden, which is how you'd stage content for review in the editor. Two writes to the same article at once can fail with 409 Conflict; read the article again and retry. Tags and scope also live on the article; change them with PUT /v1/kb/articles/{id} the same way, which changes the article but not its versions.
If your knowledge base requires review before publishing, the publish PUT fails until the draft is approved. A key without permission to publish gets 403, and a draft that still needs approval gets 409 with source.parameter: "status". Either leave publishedAt out and let a reviewer publish in Kustomer, or use a key whose role can publish.
4. Publish translations
A translation is a version in another language. Publish it the same way as an update: create a version in the language, then publish its draft. Call publishArticle with each translation after its source, using the same id and isTranslation: true, so translations don't flag each other as out of date.
When the source language changes, markOthersOutOfDate: true marks every other language's live version outdated. Those translations stay live, and latestLangs on the article shows which languages need work:
async function outdatedTranslations(): Promise<{ article: string; lang: string }[]> {
const out = []
for (let page = 1; ; page++) {
const { data, links } = await kfetch(`/v1/kb/articles?page=${page}&pageSize=100&includeBody=false`).then((r) => r.json())
for (const a of data) {
for (const [lang, v] of Object.entries(a.attributes.latestLangs ?? {})) {
if ((v as { outdated?: boolean }).outdated) out.push({ article: a.attributes.externalId ?? a.id, lang })
}
}
if (!links?.next) return out
}
}Publishing a new version in a language clears its flag. If a translation is fine as it is, clear the flag with outdated: false on PUT …/versions/{lang}/latest.
5. Remove or hide articles
When an article leaves your source, take it down in one of two ways:
Internal articles show in the agent desktop but never in the help center, the chat widget or the public endpoints. To limit one to certain teams, set accessTeams on its draft and publish.
6. Suggest articles on your own site
The /p/v3 endpoints are the customer view: no API key, and only published, public versions. Call them on your org host, which serves the default knowledge base in every region. Pass knowledgeBaseId for another one.
/p/v3/kb/articles/search
// orgHost is like https://acme.api.kustomerapp.com, and helpCenter like https://acme.kustomer.help
async function suggest(orgHost: string, helpCenter: string, term: string, lang = 'en_us') {
const url = new URL('/p/v3/kb/articles/search', orgHost)
url.search = new URLSearchParams({ term, lang, pageSize: '5' }).toString()
const { data } = await fetch(url).then((r) => r.json())
return data.map((v: { attributes: { title: string; slug: string; hash: string; lang: string } }) => ({
title: v.attributes.title,
href: `${helpCenter}/${v.attributes.lang}/${v.attributes.slug}-${v.attributes.hash}`,
}))
}Each result is a published version, and attributes.article is its parent article's ID. An article's address is /<lang>/<slug>-<hash> on the help center's domain, which the search response doesn't include, so pass it in: kustomer.help for US orgs, kustomer.support for EU orgs and kustomer.info for India orgs, or your custom domain if you've set one up. Debounce the search as people type, and send the customer's language as lang.
7. Run it in production
Sync on change, reconcile nightly
Run
publishArticlefrom your CMS's publish webhook or your docs repo's CI. A nightly full run, which skips unchanged articles by hash, catches anything a webhook missed.Schedule launches
For content that should go live with a release, leave the draft unpublished and create a schedule with
publishAtand an IANAtz. Publishing by hand or deleting the draft cancels it.Mind the rate limit
A first sync of thousands of articles is two or three requests each. Run it through one limiter and leave headroom for other integrations. See Rate limits.
Rewrite your own links
Links between articles in your source won't match help center addresses. After publishing, rewrite them to
/<lang>/<slug>-<hash>using the versions you got back, and republish the articles that link to others.
Related
- Articles, categories and knowledge bases reference
- Search recipes, for finding conversations that mention an article
- Chat SDKs, which show knowledge base articles in the widget