---
title: Editing article content | Featurebase
description: 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](/api/index.md)). 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.

## 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`

:::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.

## 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.

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',
});
```

### 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

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.

### 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: 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`.

### 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

`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

```
{
  "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

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,
  });
}
```

## 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.
