Skip to content
Dashboard

Editing article content

How to write and edit Help Center article bodies without losing formatting – the raw formatter for lossless round-trips and the find/replace edit_body endpoint for surgical, byte-stable edits.

Help Center articles store their body as Featurebase-canonical HTML (standard HTML plus a small set of custom block tags). Two features let you edit that body safely from the API:

  • The formatter field on article create and update, which controls how your input is processed before it is stored.
  • POST /v2/help_center/articles/{id}/edit_body, a find/replace endpoint for surgical, byte-stable edits – ideal for AI agents renaming a product across many articles.

Both live under /v2/help_center and use your regular API key:

Authorization: Bearer sk_...

Note: edit_body requires API version 2026-01-01.nova or newer. The raw formatter is available on 2025-12-12.clover and newer.

Every article create (POST /v2/help_center/articles) and update (PATCH /v2/help_center/articles/{id}) accepts a formatter alongside body. It decides how the body you send is transformed before storage.

formatterWhat it doesUse it when
rawStores your Featurebase-canonical HTML verbatim after an XSS-safe allowlist sanitization. Alignment, colors, classes, ids, blank-line <p></p> spacers, accordions, columns and table colwidth are preserved byte-for-byte.You are re-saving a body you previously read from this API. It is the only formatter that round-trips our own HTML losslessly.
defaultApplies lossy cleanup meant for foreign HTML/markdown – strips inline styles, most classes and blank-line spacers. Accepts CommonMark + GFM markdown.Importing plain HTML or markdown that was not produced by Featurebase.
aiRuns the input through Featurebase AI to convert arbitrary markdown / HTML / docs from other tools into our custom block format (callouts, multi-code, accordions, columns).One-off ingestion of foreign content from another tool.

The critical rule: never send default or ai for a body you read back from the API. Both are lossy for canonical HTML and will quietly strip formatting. When your workflow is “read the article, change something, write it back”, always use formatter: "raw".

:::note[Live vs draft — read and write the same state] GET /v2/help_center/articles/{id} returns the live (published) body by default; pass ?state=draft to read the draft instead. Both update (state) and edit_body (target) write to the draft by default. So a naive “read then write” reads the live body but saves a draft, and the published article looks unchanged. Keep the two ends consistent: either read the draft when you intend to write a draft, or publish the write by sending state: "live" on update (or target: "live" on edit_body). :::

Terminal window
# 1. Read the current LIVE body (GET returns state=live by default)
curl https://do.featurebase.app/v2/help_center/articles/1234567 \
-H "Authorization: Bearer sk_..."
# 2. Write it back verbatim (plus your change) with the raw formatter.
# A plain update saves to the DRAFT; add "state": "live" to publish the
# same body you just read.
curl -X PATCH https://do.featurebase.app/v2/help_center/articles/1234567 \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
"formatter": "raw",
"state": "live",
"body": "<h1 data-align=\"center\">Getting started</h1><p>Updated intro…</p>"
}'
import Featurebase from 'featurebase';
const client = new Featurebase({ apiKey: process.env.FEATUREBASE_API_KEY });
// retrieve() returns the live body by default (matches the write below)
const article = await client.helpCenter.articles.retrieve('1234567');
await client.helpCenter.articles.update('1234567', {
formatter: 'raw',
state: 'live', // publish; omit to save the change as a draft
body: article.body.replace('Getting started', 'Quickstart'),
});

For anything beyond a small string change, prefer the edit_body endpoint below – it does the read-modify-write for you and never touches the surrounding markup.

POST /v2/help_center/articles/{id}/edit_body applies a find/replace instruction to the stored body without re-uploading the whole HTML. It is designed for high-precision, byte-stable edits: everything outside the replaced spans stays identical.

Terminal window
curl -X POST https://do.featurebase.app/v2/help_center/articles/1234567/edit_body \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
"oldText": "Old Product Name",
"newText": "New Product Name"
}'
await client.helpCenter.articles.editBody('1234567', {
oldText: 'Old Product Name',
newText: 'New Product Name',
});
FieldRequiredDescription
oldTextyesExact fragment to find in the stored body. Max 10,000 characters.
newTextyesReplacement text. May be an empty string to delete the matched fragment.
replaceAllnoReplace every match. Default false → exactly one match is required (otherwise 400 with the match count).
expectedReplacementsnoAssert the exact number of matches. Replaces all matches only if the count equals this value, else 400. Takes precedence over replaceAll.
dryRunnoDefault false. When true, previews the edit without writing.
localenoLocale of the body to edit. Defaults to the help center’s default locale.
targetnodraft (default) edits the unpublished draft; live edits the published content.

Matching runs against the article’s stored body in two tiers:

  1. Text nodes only (preferred, returns mode: "text"). oldText is matched only inside visible text – markup, tags and attributes are never searched or altered, so the result is byte-identical everywhere outside the replaced spans. Matching is entity-aware: oldText: "A & B" matches a stored A &amp; B. Matches never span element boundaries, so a fragment that is split by a tag (for example a partially bolded phrase) will not match across the boundary.
  2. Raw markup fallback (returns mode: "html"). Only used when tier 1 finds nothing. oldText is matched as a literal substring over the raw body string, markup included, so you can swap an attribute value or a tag. Because this can introduce markup, the full result is re-run through the XSS sanitizer before it is stored.

Do not include <img> tags in oldText – their stored src differs from the API representation and will not match. Images inserted through edit_body are also not uploaded or ingested into Featurebase storage – for adding or changing images, use articles.update with formatter: "raw" instead.

By default the edit requires oldText to match exactly once and returns 400 if it is ambiguous. To edit multiple occurrences you have two options:

  • Set replaceAll: true to replace every match, or
  • Set expectedReplacements: N to replace all matches only when there are exactly N of them – a safety assertion that fails loudly if the article changed since you counted.

If a single match is what you want but oldText appears more than once, add more surrounding context to make it unique instead of reaching for replaceAll.

target: "draft" (the default) edits the unpublished draft. target: "live" edits the published content directly – this records a history snapshot and purges the public edge cache, mirroring a publish.

edit_body is not idempotent – a successful call mutates the body, so a blind retry can apply the same change twice. If a request times out or the result is unclear, re-read the article (or re-issue the call with dryRun: true) before retrying rather than firing it again. In the default unique-match mode a duplicate retry usually fails safely with a 400 once the original oldText is gone – the exception is when newText still contains oldText, in which case the second call matches the just-written text and edits it again.

{
"object": "article.body_edit",
"id": "1234567",
"locale": "en",
"target": "draft",
"mode": "text",
"matches": 1,
"replaced": 1,
"dryRun": false,
"previews": [
{
"before": "…see the Old Product Name dashboard for…",
"after": "…see the New Product Name dashboard for…"
}
]
}

mode tells you which tier applied. matches is how many occurrences were found; replaced is how many were written (always 0 when dryRun is true). previews contains up to 20 before/after windows, one per changed span, each framed by up to 60 characters of surrounding context.

For edits across several articles – renaming a product, fixing a recurring typo – use the dry-run workflow so you never write a surprise:

  1. Dry-run every target article with dryRun: true and inspect matches and previews.

    Terminal window
    curl -X POST https://do.featurebase.app/v2/help_center/articles/1234567/edit_body \
    -H "Authorization: Bearer sk_..." \
    -H "Content-Type: application/json" \
    -d '{
    "oldText": "Old Product Name",
    "newText": "New Product Name",
    "replaceAll": true,
    "dryRun": true
    }'
  2. Verify the reported counts and the before/after previews for each article.

  3. Commit each edit with dryRun: false and pin the count you just verified with expectedReplacements, so the write fails if the article changed underneath you:

    Terminal window
    curl -X POST https://do.featurebase.app/v2/help_center/articles/1234567/edit_body \
    -H "Authorization: Bearer sk_..." \
    -H "Content-Type: application/json" \
    -d '{
    "oldText": "Old Product Name",
    "newText": "New Product Name",
    "expectedReplacements": 3
    }'
const targets = ['1234567', '2345678', '3456789'];
// 1 + 2. Dry-run and collect counts
const plan = await Promise.all(
targets.map(async (id) => {
const preview = await client.helpCenter.articles.editBody(id, {
oldText: 'Old Product Name',
newText: 'New Product Name',
replaceAll: true,
dryRun: true,
});
return { id, matches: preview.matches, previews: preview.previews };
}),
);
// 3. Commit each edit, asserting the count you verified
for (const { id, matches } of plan) {
if (matches === 0) continue;
await client.helpCenter.articles.editBody(id, {
oldText: 'Old Product Name',
newText: 'New Product Name',
expectedReplacements: matches,
});
}
StatusCodeCause
400invalid_idThe article ID format is invalid.
400business_validation_erroroldText matched nothing; matched more than once without replaceAll/expectedReplacements; the match count did not equal expectedReplacements; or the requested locale/target has no body.
404article_not_foundNo article exists with this ID in your organization’s help center.

When a match is ambiguous or the count assertion fails, the 400 message includes the number of matches found, so you can adjust oldText or expectedReplacements and retry.