## Edit an article body (find/replace)

**post** `/v2/help_center/articles/{id}/edit_body`

Surgically edit an article's body with a find/replace instruction, without re-uploading the whole HTML. Designed for AI agents doing high-precision, byte-stable edits (e.g. renaming a product across many articles).

### Two-tier matching

Matching runs against the article's **stored** body and is tried in two tiers:

1. **Text nodes only** (`mode: "text"`, preferred): `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, so `oldText: "A & B"` matches a stored `A &amp; B`.
1. **Raw markup fallback** (`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 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.

### Uniqueness rule

By default the edit requires `oldText` to match **exactly once** and returns `400` if it is ambiguous. To edit multiple occurrences, either set `replaceAll: true` or assert the exact count with `expectedReplacements` (which takes precedence over `replaceAll`).

### Dry-run workflow

Set `dryRun: true` to preview: the response reports the match count, the matching `mode`, and `previews` (each changed span framed by surrounding context) without writing. Recommended flow for bulk edits: dry-run across the target articles first, confirm the counts and previews, then re-issue each call with `dryRun: false`.

### Target

`target: "draft"` (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.

### Retry semantics

Calls are not idempotent — after a timeout, re-read the article (or dry-run) before retrying; with the default unique-match mode a duplicate retry fails safely unless `newText` still contains `oldText`.

### Version Availability

This endpoint is only available in API version 2026-01-01.nova and newer.

### Path Parameters

- `id: string`

  Article unique identifier

### Header Parameters

- `"Featurebase-Version": optional "2026-01-01.nova" or "2025-12-12.clover"`

  - `"2026-01-01.nova"`

  - `"2025-12-12.clover"`

### Body Parameters

- `newText: string`

  Replacement text. Keep this to a fragment, not a whole document (max 10000 chars). May be an empty string to delete the matched fragment. When the match came from tier 2 (raw markup), the full resulting body is re-run through the XSS sanitizer, so any dangerous markup in `newText` is stripped. Images inserted via edit_body are not uploaded to Featurebase storage — use articles.update with formatter 'raw' for image changes.

- `oldText: string`

  Exact fragment to find in the stored article body. Keep this to a fragment, not a whole document (max 10000 chars). Tier 1 matches only inside TEXT nodes (markup, tags, and attributes are never searched or altered) and is entity-aware, so `oldText: "A & B"` matches a stored `A &amp; B`. If tier 1 finds nothing, tier 2 falls back to a literal substring match over the raw body string (markup included). Do NOT include `<img>` tags — their stored `src` differs from the API representation and will not match.

- `dryRun: optional boolean`

  Preview the edit without writing. Returns the match count, matching `mode`, and `previews` (changed spans with surrounding context) so an agent can verify the edit before committing. Recommended workflow: dry-run across a set of articles first, confirm the counts and previews, then re-issue each call with `dryRun: false`.

- `expectedReplacements: optional number`

  Assert the exact number of matches. When set, the edit replaces all matches only if the count is exactly this value, otherwise it returns `400`. Takes precedence over `replaceAll`. Use this to make bulk edits safe: dry-run first to learn the count, then commit with the expected number.

- `locale: optional "bn" or "bs" or "pt-BR" or 39 more`

  Locale of the body to edit. Defaults to the help center's default locale.

  - `"bn"`

  - `"bs"`

  - `"pt-BR"`

  - `"bg"`

  - `"ca"`

  - `"hr"`

  - `"cs"`

  - `"da"`

  - `"nl"`

  - `"en"`

  - `"et"`

  - `"fi"`

  - `"fr"`

  - `"de"`

  - `"el"`

  - `"hi"`

  - `"hu"`

  - `"id"`

  - `"it"`

  - `"ja"`

  - `"ko"`

  - `"lv"`

  - `"lt"`

  - `"ms"`

  - `"mn"`

  - `"nb"`

  - `"pl"`

  - `"pt"`

  - `"ro"`

  - `"ru"`

  - `"sr"`

  - `"zh-CN"`

  - `"sk"`

  - `"sl"`

  - `"es"`

  - `"sw"`

  - `"sv"`

  - `"th"`

  - `"zh-TW"`

  - `"tr"`

  - `"uk"`

  - `"vi"`

- `replaceAll: optional boolean`

  Replace every match. When omitted (or false) and `expectedReplacements` is not set, the edit requires exactly one match and returns `400` if `oldText` is ambiguous (found more than once). Provide more surrounding context in `oldText` to make a unique match, or set this to true.

- `target: optional "draft" or "live"`

  Which body to edit: the unpublished `draft` (default) or the published `live` content. Editing `live` records a history snapshot and purges the public edge cache, mirroring a publish.

  - `"draft"`

  - `"live"`

### Returns

- `EditArticleBodyResponse object { id, dryRun, locale, 6 more }`

  - `id: string`

    The edited article id

  - `dryRun: boolean`

    Whether this was a preview (no write performed)

  - `locale: string`

    Locale that was edited

  - `matches: number`

    Number of matches found for `oldText`

  - `mode: "text" or "html"`

    Which matching tier applied. `text` = text-node-only, byte-stable (markup untouched). `html` = raw-markup fallback (result re-sanitized). Tier 1 (`text`) is always attempted first.

    - `"text"`

    - `"html"`

  - `object: "article.body_edit"`

    Object type identifier

    - `"article.body_edit"`

  - `previews: array of EditArticleBodyPreview`

    Up to 20 before/after windows, one per replaced match, for verification. Preview strings are raw untrusted text (tier-2 previews show pre-sanitization content) — never render them as HTML.

    - `after: string`

      The same span and window after the edit.

    - `before: string`

      The changed span framed by up to 60 characters of surrounding context, before the edit.

  - `replaced: number`

    Number of matches actually replaced (0 when `dryRun` is true)

  - `target: "draft" or "live"`

    Which body was edited

    - `"draft"`

    - `"live"`

### Example

```http
curl https://do.featurebase.app/v2/help_center/articles/$ID/edit_body \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $FEATUREBASE_API_KEY" \
    -d '{
          "newText": "New Product Name",
          "oldText": "Old Product Name",
          "dryRun": true,
          "expectedReplacements": 3,
          "locale": "en",
          "target": "draft"
        }'
```

#### Response

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