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
formatterfield 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.
Choosing a formatter
Section titled “Choosing a formatter”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.
formatter | What it does | Use it when |
|---|---|---|
raw | Stores 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. |
default | Applies 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. |
ai | Runs 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".
Read-modify-write with raw
Section titled “Read-modify-write with 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).
:::
# 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.
Surgical edits with edit_body
Section titled “Surgical edits with edit_body”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.
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',});Parameters
Section titled “Parameters”| Field | Required | Description |
|---|---|---|
oldText | yes | Exact fragment to find in the stored body. Max 10,000 characters. |
newText | yes | Replacement text. May be an empty string to delete the matched fragment. |
replaceAll | no | Replace every match. Default false → exactly one match is required (otherwise 400 with the match count). |
expectedReplacements | no | Assert the exact number of matches. Replaces all matches only if the count equals this value, else 400. Takes precedence over replaceAll. |
dryRun | no | Default false. When true, previews the edit without writing. |
locale | no | Locale of the body to edit. Defaults to the help center’s default locale. |
target | no | draft (default) edits the unpublished draft; live edits the published content. |
Two-tier matching
Section titled “Two-tier matching”Matching runs against the article’s stored body in two tiers:
- Text nodes only (preferred, returns
mode: "text").oldTextis 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 storedA & 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. - Raw markup fallback (returns
mode: "html"). Only used when tier 1 finds nothing.oldTextis 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.
Uniqueness
Section titled “Uniqueness”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: trueto replace every match, or - Set
expectedReplacements: Nto replace all matches only when there are exactlyNof 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.
Draft vs live
Section titled “Draft vs live”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.
Retries
Section titled “Retries”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.
Response
Section titled “Response”{ "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.
Safe bulk edits
Section titled “Safe bulk edits”For edits across several articles – renaming a product, fixing a recurring typo – use the dry-run workflow so you never write a surprise:
-
Dry-run every target article with
dryRun: trueand inspectmatchesandpreviews.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}' -
Verify the reported counts and the before/after previews for each article.
-
Commit each edit with
dryRun: falseand pin the count you just verified withexpectedReplacements, 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 countsconst 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 verifiedfor (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, });}Errors
Section titled “Errors”| Status | Code | Cause |
|---|---|---|
400 | invalid_id | The article ID format is invalid. |
400 | business_validation_error | oldText matched nothing; matched more than once without replaceAll/expectedReplacements; the match count did not equal expectedReplacements; or the requested locale/target has no body. |
404 | article_not_found | No 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.