# Migrate a knowledge base as an AI agent

_Category: AI agents and MCP_

An AI agent can move its user's knowledge base to HelpCenter.io with no account: it creates an unpublished help center over the REST API, fills it, and gives its user one link to claim it. This page is the whole recipe, each request with the answer it gets. An agent that works through MCP tools can do the same through the HelpCenter.io MCP server, with no sign-in: see "Over MCP" below.

## Before you start

- **No account and no key.** The first request creates the help center and answers with its key. Every later request sends that key in the `apikey` header.
- **Every request** goes to `https://api.helpcenter.io/v1` with `Accept: application/json`, and every request with a body also sends `Content-Type: application/json`. See [Requests and responses](https://developers.helpcenter.io/content/requests-responses-and-conventions).
- **Only content your user owns or may copy.** When you name the source or import it, the claim page asks your user to confirm that they own it or may copy its content.
- **Act only with your user's authority.** Before you send a request, read the [Terms of Service](https://helpcenter.io/legal/terms#agents) on API and agent use, and make sure your user has asked you to act for them. Send someone's email address only with that person's authority: sending it doesn't mean they accept the Terms or agree to marketing.
- **Written for agents too.** The same recipe, as instructions an agent can follow, is the skill at `https://helpcenter.io/.well-known/agent-skills/migrate-to-helpcenter-io/SKILL.md`, and `https://api.helpcenter.io/openapi.json` describes every endpoint on this page.

### Choose how the content arrives

| Your user's knowledge base | What to do |
| --- | --- |
| Up to 500 articles, in one language | Build it yourself: steps 1 to 4, then hand it over in step 5. |
| A public Zendesk Guide, Intercom, Document360, Help Scout Docs, Front Knowledge Base or HelpDocs help center | Create the help center (step 1), then let HelpCenter.io's importer bring the content: see "Import a supported help center" below. |
| More than 500 articles, several languages, several knowledge bases, or one you can't read | File a migration request: see "Ask for a migration" below. |

## Over MCP

The HelpCenter.io MCP server has an endpoint for this flow: `https://mcp.helpcenter.io/agent`. Add it to your MCP client as a remote server (streamable HTTP) with no sign-in and no credential: it never asks for one, and ignores any header you send. Each step on this page is a tool there:

| Step | Tools |
| --- | --- |
| 1. Create the help center | `helpcenter_create_help_center`, with `name`, `source_url` and `language`. Its `structuredContent` is the answer shown in step 1: keep `api_key`, `site_id` and `claim_url`. |
| 2. Create the categories | `helpcenter_create_category` |
| 3. Send the articles | `helpcenter_bulk_create_articles`, up to 50 a call, each with its `external_id`; or `helpcenter_create_article` for one |
| Import a supported help center | `helpcenter_scan_import`, `helpcenter_choose_import` and `helpcenter_run_import`, then `helpcenter_get_import` until `status` is `completed` or `failed`. A scan can outlast its call: `helpcenter_list_imports` finds it when it finishes. |
| 4. Check what arrived | `helpcenter_get_help_center`: counts and limits, `preview_url` and `next_step` |
| 5. Give your user the claim link | No tool: give your user `claim_url` |
| Ask for a migration | `helpcenter_request_migration`, then `helpcenter_get_migration_request` with the request's `status_token` |
| 6. Finish setting up after the claim | `helpcenter_set_custom_domain` and `helpcenter_get_custom_domain`; `helpcenter_get_import_redirect_map` and `helpcenter_bulk_create_redirects`, and the other redirect tools |
| Delete a help center you created | `helpcenter_discard_help_center` |

- **The key is an argument.** `helpcenter_create_help_center` returns the help center's key once, as `api_key`. Every tool that acts on the help center requires it as `agent_key`: pass it on every call, exactly as it was returned. The connection has no credential and can't take one on mid-session, so the argument is how each call says which help center it acts on. Only the create tool and the two migration-request tools take no `agent_key`. Keep the key out of what you show your user and out of the content.
- **Everything else on this page applies.** Each tool call is a request to the API with your key: the limits, the claim, what your key may reach before and after it, and the codes in "Errors" are the same. A refused call comes back as a tool error that says what the API answered and what to do.
- **Creating counts in a shared pool.** Every agent on the endpoint reaches the API from the MCP server's address, so it doesn't use your address's 1 an hour and 3 a day. In ChatGPT, creating counts in a pool shared by every ChatGPT agent; from any other client, in the MCP server's own: 60 help centers an hour and 300 open at once, across every agent that uses it. Migration requests through the endpoint are limited to 30 a day across every agent that uses it, ChatGPT included. On `limit_reached` or `capacity_reached`, file a migration request.
- **What it leaves out.** The endpoint has no tools for images, change sets or deleting an article. The article tools copy the images an article's content references when `rehost_images` is on, which it is by default through MCP. It has no comment, analytics or team note tools either, which your key can't use, before the claim or after.
- **The design tools follow the same rules.** The script tools are listed, but a change to the design's scripts or HTML answers `agent_scripts_forbidden`, as it does over HTTP.

Every tool's arguments are in the [MCP tool reference](https://developers.helpcenter.io/content/mcp-tool-reference).

## 1. Create the help center

Send your user's product or company name, the address of the knowledge base you are moving, and its main language. This request takes no credential:

```
curl -sS -X POST "https://api.helpcenter.io/v1/agent/help-centers" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme", "source_url": "https://help.acme.example", "language": "en"}'
```

The answer is `201 Created`:

```
{
  "site_id": 375,
  "subdomain": "agent-5uqsr4jw",
  "api_key": "<your agent key: shown only in this answer>",
  "claim_url": "https://helpcenter.io/claim/<claim token>",
  "expires_at": "2026-11-06T08:22:42+00:00",
  "limits": {
    "articles": 500,
    "categories": 100,
    "languages": 5,
    "storage_bytes": 524288000
  },
  "docs_url": "https://developers.helpcenter.io/content/migrate-a-knowledge-base-as-an-ai-agent",
  "terms_url": "https://helpcenter.io/legal/terms"
}
```

Keep `api_key` and `site_id`: the key is never shown again. The examples on this page read them from the environment:

```
export HELPCENTER_API_KEY="the api_key from the answer"
export SITE_ID=375
```

| Field | Description |
| --- | --- |
| `name` | Required. Your user's product or company, up to 100 characters. A name that contains a protected brand name answers `422` with `name_blocked`. |
| `source_url` | The knowledge base you are moving, `https` only. It is shown on the claim page, and a migration request for this help center uses it by default. HelpCenter.io doesn't read it now. |
| `language` | The help center's language, such as `en`, `de` or `pt`. `en` when you leave it out. |

The new help center is unpublished, hidden from search engines, and on a temporary address: `https://agent-5uqsr4jw.helpcenter.io` here. Nobody else can see it until your user claims and publishes it: until then that address answers `404` with "This help center is not published yet", so check your work through `preview_url` (step 4). It has one language, the one you created it with, and every title and body you send is keyed by that language's code. An import can add the source's other languages. It holds up to 500 articles and 100 categories, drafts and Trash included. It expires at `expires_at`, 7 days after creation, unless your user claims it first: its key stops working then, and the help center is deleted soon after.

Treat `claim_url` like a password: anyone who has it can claim the help center. Give it only to your user (step 5), and keep it and the key out of logs.

`terms_url` is the address of the Terms of Service that cover what you do through the API for your user. Like `claim_url`, it is always `https`.

One address can create 1 help center an hour and 3 a day, and an IPv6 address counts with the rest of its /64. Past that, the answer is `429 Too Many Requests` with a `Retry-After` header:

```
{
  "status": "error",
  "code": "limit_reached",
  "limit": "help_centers_per_hour",
  "max": 1,
  "message": "This address has created a help center in the last hour, the most it may without an account. To migrate more knowledge bases, file a migration request with POST /v1/agent/migration-requests: one request covers up to 50 help centers.",
  "migration_request_endpoint": "POST /v1/agent/migration-requests"
}
```

A `503` with `capacity_reached` means as many unclaimed help centers are open as HelpCenter.io allows at once. For either, file a migration request instead (see "Ask for a migration" below).

## 2. Create the categories

Create each category of the source, parents before their children, and keep the `category.id` of each answer:

```
curl -sS -X POST "https://api.helpcenter.io/v1/categories" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name": {"en": "Billing"}, "parent_id": null}'
```

The answer is `201 Created`:

```
{
  "status": "success",
  "category": {
    "id": 292,
    "parent": null,
    "name": {
      "en": "Billing"
    },
    "description": [],
    "icon": null,
    "position": 0,
    "privacy": "public",
    "created_at": "2026-10-30 08:22:42",
    "updated_at": "2026-10-30 08:22:42"
  }
}
```

A subcategory names its parent: `{"name": {"en": "Invoices"}, "parent_id": 292}`. Every field is in [Categories](https://developers.helpcenter.io/content/categories-api).

## 3. Send the articles

Send the articles 50 at a time with `POST /v1/articles/bulk`:

```
curl -sS -X POST "https://api.helpcenter.io/v1/articles/bulk" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"articles": [
    {
      "external_id": "https://help.acme.example/hc/en-us/articles/360001234-Change-your-plan",
      "title": {"en": "Change your plan"},
      "content": {"en": "<p>Open <strong>Billing</strong>, then choose <strong>Change plan</strong>.</p>"},
      "categories": [292],
      "published": true,
      "rehost_images": true
    },
    {
      "external_id": "https://help.acme.example/hc/en-us/articles/360001235-Download-an-invoice",
      "title": {"en": "Download an invoice"},
      "content": {"en": "Open **Billing**, then **Invoices**, and choose **Download**."},
      "content_format": "markdown",
      "categories": [293],
      "published": true,
      "rehost_images": true
    }
  ]}'
```

The answer is `207 Multi-Status`, with one result per article in the order you sent them. The first result is below; the second has the same shape:

```
{
  "status": "success",
  "results": [
    {
      "index": 0,
      "status": "created",
      "article": {
        "id": 519,
        "external_id": "https://help.acme.example/hc/en-us/articles/360001234-Change-your-plan",
        "title": {
          "en": "Change your plan"
        },
        "slug": {
          "en": "change-your-plan"
        },
        "author": {
          "id": 411,
          "name": "AI agent",
          "avatar": null
        },
        "content": {
          "en": "<p>Open <strong>Billing</strong>, then choose <strong>Change plan</strong>.</p>"
        },
        "category_id": -1,
        "categories": [
          292
        ],
        "type": null,
        "published": true,
        "has_staged_changes": false,
        "staged_locales": [],
        "staged_updated_at": null,
        "visibility": "public",
        "share_token": null,
        "views": null,
        "audio_file": null,
        "metadata": {
          "title": {},
          "description": {}
        },
        "ratings": {
          "thumbs_up": 0,
          "thumbs_down": 0,
          "love": 0
        },
        "published_translations": [],
        "shared_with": [],
        "created_at": "2026-10-30 08:22:56",
        "updated_at": "2026-10-30 08:22:56",
        "_links": {
          "view": {
            "method": "GET",
            "url": "https://agent-5uqsr4jw.helpcenter.io/content/change-your-plan"
          }
        }
      },
      "images": {
        "rehosted": 0,
        "reused": 0,
        "failed": [],
        "failed_count": 0
      }
    }
  ],
  "meta": {
    "total": 2,
    "created": 2,
    "updated": 0,
    "failed": 0
  }
}
```

- **`external_id`** is the article's address in the source, exactly as the source gives it, up to 191 characters. Sending the same `external_id` again updates that article instead of adding a second one: the result's `status` is then `updated`. So a retry after a timeout or an error is always safe.
- **`published: true`** for the articles that are live in the source. Leave it out for a draft.
- **`rehost_images: true`** copies the source's images into the help center, so they keep working when the old knowledge base is gone. `images` in each result says how many were copied and which failed.
- **`content_format: "markdown"`** on an article whose `content` is Markdown. HTML is the default.
- **`categories`** takes the ids as JSON numbers. An id sent as a string, such as `["292"]`, is refused with `One or more of the categories attached does not exist.`
- Retry only the results whose `status` is `error`. Each holds the `external_id` you sent and an `error` with a `code`, a `message` and, for a validation error, the `errors` by field. An `error.code` of `limit_reached` means the help center is full: stop, and ask for a migration for the rest.
- Send at most 20 bulk requests a minute from one address. Past that, wait `Retry-After` seconds.

Every field of an article is in [Articles](https://developers.helpcenter.io/content/articles-api), and [Import articles in bulk](https://developers.helpcenter.io/content/import-articles-in-bulk) has a complete script that maps categories and sends a file of articles in batches.

## 4. Check what arrived

Read the help center's status with its key:

```
curl -sS "https://api.helpcenter.io/v1/agent/help-centers/$SITE_ID" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json"
```

The answer is `200 OK`:

```
{
  "site_id": 375,
  "subdomain": "agent-5uqsr4jw",
  "claimed": false,
  "claim": {
    "state": "open",
    "held_until": null
  },
  "expires_at": "2026-11-06T08:22:42+00:00",
  "counts": {
    "articles": 2,
    "categories": 2,
    "languages": 1,
    "storage_bytes": 0
  },
  "limits": {
    "articles": 500,
    "categories": 100,
    "languages": 5,
    "storage_bytes": 524288000
  },
  "claim_url": "https://helpcenter.io/claim/<claim token>",
  "preview_url": "https://agent-5uqsr4jw.helpcenter.io?hc_preview=<preview token>",
  "next_step": {
    "code": "not_claimed",
    "instruction": "Give your user this link to claim the help center: they sign up or sign in with an email address they verify. Nobody else can see it until they claim and publish it, and it is deleted if nobody claims it in time.",
    "url": "https://helpcenter.io/claim/<claim token>"
  },
  "setup": {
    "claimed": false,
    "email_verified": false,
    "plan_activated": false,
    "next_step": {
      "code": "not_claimed",
      "instruction": "Give your user this link to claim the help center: they sign up or sign in with an email address they verify. Nobody else can see it until they claim and publish it, and it is deleted if nobody claims it in time.",
      "url": "https://helpcenter.io/claim/<claim token>"
    }
  },
  "capabilities": {
    "content": {
      "enabled": true,
      "requires": []
    },
    "design": {
      "enabled": true,
      "requires": []
    },
    "redirects": {
      "enabled": false,
      "requires": [
        "claimed",
        "email_verified",
        "plan_activated"
      ]
    },
    "custom_domain": {
      "enabled": false,
      "requires": [
        "claimed",
        "email_verified",
        "plan_activated"
      ]
    },
    "preview": {
      "enabled": true,
      "requires": []
    }
  }
}
```

Compare `counts` with what you read from the source and send what is missing again. `counts` includes drafts and articles in Trash, and its `storage_bytes` is what the help center's images and files take up.

Then open `preview_url` to see the help center as its readers will, page by page, under a **Preview: not yet published** banner. The link works for an hour, read-only, and shows only published, public articles; every read of the status gives you a new one.

## Import a supported help center

When the source is a public help center on Zendesk Guide, Intercom, Document360, Help Scout Docs, Front Knowledge Base or HelpDocs, HelpCenter.io's importer can read it for you and bring its categories, articles, languages and images. Create the help center first (step 1), then scan, choose and run the import with the help center's key. Use it instead of steps 2 and 3. Every field of an import is in [Imports](https://developers.helpcenter.io/content/imports-api).

### Scan the source

Send the address readers open, such as the help center's home page:

```
curl -sS -X POST "https://api.helpcenter.io/v1/imports" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  --max-time 90 \
  -d '{"url": "https://help.acme.example/hc/en-us"}'
```

The scan reads the source while you wait, for up to 40 seconds, so give the request at least a minute. The answer is `201 Created` with the import and its plan:

```
{
  "status": "success",
  "import": {
    "id": 1,
    "platform": "zendesk",
    "importable": true,
    "source_url": "https://help.acme.example/hc/en-us",
    "source_host": "help.acme.example",
    "status": "scanned",
    "agent_initiated": true,
    "plan": {
      "articles": 4,
      "categories": [
        {
          "id": "c100",
          "name": "Getting started",
          "articles": 3
        },
        {
          "id": "c101",
          "name": "Billing",
          "articles": 1
        }
      ],
      "languages": [
        {
          "code": "en-us",
          "label": "English",
          "articles": 4,
          "lands_in": "en"
        },
        {
          "code": "de",
          "label": "Deutsch",
          "articles": 1,
          "lands_in": "de"
        }
      ],
      "default_language": "en-us",
      "images": 1
    },
    "selection": {
      "category_ids": [
        "c100",
        "c101"
      ],
      "languages": [
        "en-us",
        "de"
      ],
      "arrive_as": null,
      "articles": 4
    },
    "refusal": null,
    "limit": {
      "max": 500,
      "room": 498,
      "articles_to_import": 4,
      "fits": true
    },
    "progress": null,
    "imported_articles": 0,
    "needs_review": {
      "count": 0,
      "articles": []
    },
    "error": null,
    "started_at": null,
    "finished_at": null,
    "created_at": "2026-10-27T14:00:06+00:00",
    "updated_at": "2026-10-27T14:00:06+00:00"
  }
}
```

- **`importable`** is `false`, with `platform: "other"` and `plan: null`, when the importer can't read the address. That is still a `201`: build the help center yourself, or ask for a migration. An address counts as one of those platforms only when it is on the platform's own domain, or its DNS points there with a CNAME, as a custom domain set up with the platform does.
- **`plan.categories`** are the source's top-level categories. Each `articles` count includes everything nested under it, and its sections arrive as subcategories.
- **`plan.languages`** are the source's languages. `lands_in` is the language each is written into here: the source's main language lands in the language you created the help center with, and the others are added to the help center. A language whose `lands_in` is `null` can't be imported.
- **`limit`** says whether the chosen articles fit: `room` is what is left of the 500, `articles_to_import` what the import would bring, and `fits` compares them.
- A private or very large source answers `422` with a `code`: `needs_credentials` (a private Zendesk help center, or a HelpDocs site that asks readers to sign in or uses a custom HTML template), `not_public`, `too_large` or `robots`. Your key can't send credentials for it: ask for a migration instead, or let your user connect the source from their dashboard after the claim.
- One site is read for at most two scans or runs at once, and at most 20,000 of the importer's requests a day, across every help center importing from it. Past either, a scan answers `422` with `host_busy` (try again in a few minutes) or `host_daily_limit` (try again after 00:00 UTC, or ask for a migration), and a run waits, `queued`, until there is room.
- **After the claim**, a key your user let you keep must send `"confirm_ownership": true` with the scan, to confirm that your user owns the source or may copy its content; without it the scan answers `422` with `validation_error`. Before the claim, leave it out: the claim page asks your user.
- Import one source at a time: scanning another address cancels a scan you have not run yet. Scanning the same address again before you run it scans it afresh and resets your choice of categories and languages; once you have scanned another address, the earlier scan is cancelled and the same address gives a new import.

### Choose what to bring

Send the category ids and language codes from the plan, as strings, and how the articles should arrive:

```
curl -sS -X PUT "https://api.helpcenter.io/v1/imports/1" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"category_ids": ["c100", "c101"], "languages": ["en-us", "de"], "arrive_as": "as_is"}'
```

The answer is `200 OK` with the import and its new `selection`. `category_ids` and `languages` are both required on every `PUT`, and the source's main language is always imported. Until you choose, `arrive_as` is `draft` (`null` in the answer to a first scan), and the articles arrive as drafts. `as_is` publishes the articles that are published at the source and visible to everyone there; the rest arrive as drafts. A choice that doesn't fit in the 500 articles answers `422` with `limit_reached`, `room` and `articles_to_import`, and saves nothing: choose fewer categories, or ask for a migration.

### Run it and follow it

```
curl -sS -X POST "https://api.helpcenter.io/v1/imports/1/run" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json"
```

The answer is `202 Accepted` with the import, its `status` now `queued`. Read it every 10 seconds or so until `status` is `completed` or `failed`:

```
curl -sS "https://api.helpcenter.io/v1/imports/1" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json"
```

A finished import:

```
{
  "status": "success",
  "import": {
    "id": 1,
    "platform": "zendesk",
    "importable": true,
    "source_url": "https://help.acme.example/hc/en-us",
    "source_host": "help.acme.example",
    "status": "completed",
    "agent_initiated": true,
    "plan": {
      "articles": 4,
      "categories": [
        {
          "id": "c100",
          "name": "Getting started",
          "articles": 3
        },
        {
          "id": "c101",
          "name": "Billing",
          "articles": 1
        }
      ],
      "languages": [
        {
          "code": "en-us",
          "label": "English",
          "articles": 4,
          "lands_in": "en"
        },
        {
          "code": "de",
          "label": "Deutsch",
          "articles": 1,
          "lands_in": "de"
        }
      ],
      "default_language": "en-us",
      "images": 1
    },
    "selection": {
      "category_ids": [
        "c100",
        "c101"
      ],
      "languages": [
        "en-us",
        "de"
      ],
      "arrive_as": "as_is",
      "articles": 4
    },
    "refusal": null,
    "limit": {
      "max": 500,
      "room": 494,
      "articles_to_import": 0,
      "fits": true
    },
    "progress": {
      "total": 4,
      "done": 4,
      "created": 4,
      "updated": 0,
      "conflicts": 0,
      "removed": 0,
      "images": 0,
      "image_failures": 1
    },
    "imported_articles": 4,
    "needs_review": {
      "count": 0,
      "articles": []
    },
    "error": null,
    "started_at": "2026-10-27T14:00:06+00:00",
    "finished_at": "2026-10-27T14:00:06+00:00",
    "created_at": "2026-10-27T14:00:06+00:00",
    "updated_at": "2026-10-27T14:00:06+00:00"
  }
}
```

| `status` | Meaning |
| --- | --- |
| `scanned` | Scanned, not run yet. |
| `queued` | Waiting to run. A large import also goes back to `queued` for a few seconds between two runs: keep following it. |
| `running` | Importing. `progress.done` moves in steps of 10 articles. |
| `completed` | Everything you chose is in. |
| `failed` | `error.message` says why and `error.reason` names it. What arrived stays; run it again to retry. |
| `cancelled` | Cancelled with `DELETE /v1/imports/{importId}`, or by a scan of another address before it ran. |

What arrives: categories with their hierarchy, all public; articles in their categories, published or drafts as you chose, with links between them rewritten to the new addresses; translations in the same articles; images copied into the help center (one that fails keeps its original address, and `progress.image_failures` counts it). An article that only some readers could see at the source arrives private and as a draft, and is listed in `needs_review` with a note, as is an article that changed both at the source and here since an earlier run. Imported articles get no `external_id`. Running the import again updates what it brought and adds nothing twice.

`GET /v1/imports` lists the help center's imports, newest first, and `DELETE /v1/imports/{importId}` cancels one that isn't running, keeping what it brought. Each key may scan 10 times and run 10 times a minute.

## 5. Give your user the claim link

Give your user `claim_url` and tell them what it means:

- Nobody else can see the help center until they claim it and publish it, and it expires at `expires_at`, 7 days after you created it, if nobody has claimed it.
- Claiming takes signing up or signing in with an email address they verify. With Google sign-in, or an account whose address is already verified, the claim completes at once. Otherwise the link is held for them for 24 hours while they verify their address: the status then shows `claim.state` `held` and `claim.held_until`. If they claim it into an account that already has a plan, the claim continues into plan setup, where they activate a plan for this help center.
- When you named a `source_url` or imported a source, the claim page asks them to confirm that they own it or may copy its content.
- **Let this AI agent finish setting up your help center** is ticked on the claim page. Left ticked, your key keeps working for 30 days after the claim; unticked, it stops at the claim. When your user first publishes the help center, they are asked again, as **Let the AI agent keep editing it once it's live**. That box starts unticked: unless they tick it, your key stops working the moment the help center goes live. Your user can remove it sooner in Settings, under API keys. A key you keep reaches the help center's content, its design and, once a plan is active, its redirects and custom domain, and nothing else of your user's.

HelpCenter.io sends your user no email with the link: it reaches them only through you. Don't claim the help center yourself.

## Ask for a migration

For a knowledge base too large for an unclaimed help center, one in several languages, several knowledge bases, or one you can't read, file a migration request. A public help center on Zendesk Guide, Intercom, Document360, Help Scout Docs, Front Knowledge Base or HelpDocs is migrated automatically; HelpCenter.io's migration team handles everything else. This request takes no credential:

```
curl -sS -X POST "https://api.helpcenter.io/v1/agent/migration-requests" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"email": "alex@acme.example", "sources": [
    {"url": "https://help.acme.example/hc/en-us", "name": "Acme", "article_count": 4},
    {"url": "https://wiki.acme.example", "name": "Acme internal wiki", "article_count": 1800, "language": "de"},
    {"url": "https://docs.acme.example", "agent_key": "'"$DOCS_AGENT_KEY"'"}
  ]}'
```

| Field | Description |
| --- | --- |
| `email` | Required. Your user's email address, sent only with their authority, for HelpCenter.io's team only. Nothing is sent to it automatically. Tell your user how HelpCenter.io handles it: see the [Privacy Policy](https://helpcenter.io/legal/privacy#agent-migrations). |
| `sources` | Required. A JSON array of 1 to 50 sources, one help center each. |
| `sources[].url` | Required. The knowledge base's address, `https` only. For an automatic migration, the help center's own address, not an article's or an alias. Name each knowledge base once: a request that names the same one twice answers `422` with `validation_error`. Addresses are compared without the letter case of the scheme and host, a leading `www.`, the port, a trailing slash, the query and the fragment. |
| `sources[].name` | The name of the help center created for this source, up to 100 characters. |
| `sources[].article_count` | How many articles you counted, for the team's planning. |
| `sources[].language` | The source's main language, such as `en`, `de` or `pt`: the help center created for this source has it as its main language. `en` when you leave it out. Not beside an `agent_key`, whose help center keeps the language you created it with: sending both answers `422`. |
| `sources[].note` | Anything the team should know, up to 2,000 characters. |
| `sources[].agent_key` | The key of a help center you created in step 1: the source is migrated into that help center instead of a new one. |

The third source names, as `agent_key`, the key of a help center you created earlier for `docs.acme.example`, kept here in `DOCS_AGENT_KEY`. The answer is `201 Created`:

```
{
  "request_id": 1,
  "status": "queued",
  "expected_by": "2026-10-28T14:00:33+00:00",
  "status_token": "<status token: shown only in this answer>",
  "help_centers": [
    {
      "site_id": 378,
      "subdomain": "agent-gwdkr1lw"
    },
    {
      "site_id": 379,
      "subdomain": "agent-kiqbl2j7"
    },
    {
      "site_id": 376,
      "subdomain": "agent-c1r79txu"
    }
  ],
  "handoff_url": "https://helpcenter.io/claim/<claim token>"
}
```

- `help_centers` is in the order of your `sources`. A source without an `agent_key` gets a new unpublished help center, in its `language` (English when you send none), and no key for you: here the wiki's help center is in German.
- `status_token` is shown only here. Keep it as safe as a password: it reads the request, and the request's claim link with it.
- `handoff_url` is one claim link for every help center of the request: give it to your user now (step 5). They can claim while the migration runs.
- `expected_by` is 24 hours after filing.
- Filing is not idempotent: a request whose answer you lost may have been filed, and sending it again files a second one, with new help centers.

For one help center you created, `POST /v1/agent/help-centers/{site_id}/migration-request` with its key and `{"email": "alex@acme.example"}` does the same. Its source is the `source_url` you created it with, unless you send a `url`.

### Follow the request

Send the request's `status_token` as a bearer token, here from `$STATUS_TOKEN`:

```
curl -sS "https://api.helpcenter.io/v1/agent/migration-requests/1" \
  -H "Authorization: Bearer $STATUS_TOKEN" \
  -H "Accept: application/json"
```

Here the first source was migrated automatically and the other two need a person:

```
{
  "request_id": 1,
  "status": "with_our_team",
  "expected_by": "2026-10-28T14:00:33+00:00",
  "retry_after": 900,
  "help_centers": [
    {
      "site_id": 378,
      "subdomain": "agent-gwdkr1lw",
      "status": "completed",
      "articles_migrated": 4,
      "claimed": false,
      "reason": null,
      "dashboard_url": null
    },
    {
      "site_id": 379,
      "subdomain": "agent-kiqbl2j7",
      "status": "with_our_team",
      "articles_migrated": 0,
      "claimed": false,
      "reason": null,
      "dashboard_url": null
    },
    {
      "site_id": 376,
      "subdomain": "agent-c1r79txu",
      "status": "with_our_team",
      "articles_migrated": 0,
      "claimed": false,
      "reason": null,
      "dashboard_url": null
    }
  ],
  "handoff_url": "https://helpcenter.io/claim/<claim token>",
  "next_step": {
    "code": "wait_for_migration",
    "instruction": "HelpCenter.io's migration team is moving the content. Give your user handoff_url if you have not yet, and read this status again after retry_after seconds.",
    "url": null
  }
}
```

Read it again after `retry_after` seconds; a request's status answers at most 60 times an hour. A wrong token, or another request's id, answers `404`. The request, and each help center in it, has one of these statuses:

| `status` | Meaning |
| --- | --- |
| `queued` | Waiting to start. |
| `in_progress` | Being migrated. `articles_migrated` grows as articles arrive. |
| `with_our_team` | HelpCenter.io's migration team has it: every source the automatic migration can't take goes to them. They may write to your user at `email`. If nobody on the team takes it up within 7 days, it becomes `declined`. |
| `completed` | Done. |
| `declined` | The team won't migrate it, or didn't take it up within 7 days: then `reason` starts with `no_staff_action:`. `reason` says why, for your user. If they still want it moved, build it yourself (steps 1 to 4). |

While the request is open, your key's writes to its help centers answer `409 Conflict` with `migration_in_progress`, and reads still work. The help centers don't expire while it is open: their status shows `expires_at` as `null`. When it ends, each one nobody has claimed gets a new `expires_at`, 7 days later. An article you wrote with an `external_id` that is exactly the source article's address is replaced from the source in place, while the help center is unclaimed; every other article you wrote stays as it is.

## 6. Finish setting up after the claim

Read the status again with your key. Here for help center 380, which its user has claimed and set up: after the claim it shows `claimed: true`, no `expires_at` or `claim_url`, and what is left to do:

```
{
  "site_id": 380,
  "subdomain": "agent-qfcjyvj1",
  "claimed": true,
  "claim": {
    "state": "claimed",
    "held_until": null
  },
  "expires_at": null,
  "claim_url": null,
  "next_step": {
    "code": "make_public",
    "instruction": "Ask your user to choose their help center's address and publish it from its settings when they are ready. Only they can publish it.",
    "url": "https://helpcenter.io/app/sites/agent-qfcjyvj1/settings"
  },
  "setup": {
    "claimed": true,
    "email_verified": true,
    "plan_activated": true,
    "next_step": {
      "code": "make_public",
      "instruction": "Ask your user to choose their help center's address and publish it from its settings when they are ready. Only they can publish it.",
      "url": "https://helpcenter.io/app/sites/agent-qfcjyvj1/settings"
    }
  },
  "capabilities": {
    "content": {
      "enabled": true,
      "requires": []
    },
    "design": {
      "enabled": true,
      "requires": []
    },
    "redirects": {
      "enabled": true,
      "requires": []
    },
    "custom_domain": {
      "enabled": true,
      "requires": []
    },
    "preview": {
      "enabled": true,
      "requires": []
    }
  }
}
```

The rest of the answer (`counts`, `limits`) is as before the claim; the limits no longer apply. `preview_url` is `null` until your user may publish the help center: `capabilities.preview` says what it still `requires`, and a preview link you already have answers `410` until then. `next_step` holds the first thing left, as a `code`, an `instruction` to pass on to your user as it is, and the `url` where they take the step. It is `null` once nothing is left.

| `next_step.code` | What it means |
| --- | --- |
| `wait_for_migration` | An open migration request holds the help center. Follow the request. |
| `not_claimed` | Nobody has claimed it yet, or a claim waits for an email address to be verified. `url` is the claim link: give it to your user again. |
| `email_unverified` | Your user has claimed it and must verify their email address. |
| `plan_not_activated` | Your user must choose a plan and add a payment method, or, when they claimed it into an account that already has a plan, activate a plan for this help center. `url` is the page for either. |
| `make_public` | Your user chooses the help center's address and publishes it from its settings, when they are ready. Only they can. |

`capabilities` says what your key may use now. `content` and `design` are always `enabled`. `redirects` and `custom_domain` become `enabled` once the help center is claimed and its owner has verified their email address and activated a plan; until then a call to them answers `403 Forbidden` with `capability_locked`, the conditions it still `requires`, and the same `next_step`. The custom domain also needs your key's own permission to manage it, which a key you keep has from the claim: if your user turns off **Allow managing the custom domain** for it in Settings, under API keys, `/v1/custom-domain` answers `403` with `insufficient_scope` even while `custom_domain.enabled` is `true`. If your user didn't let you finish setting up, your key answers `401` and your part is done.

### Connect your user's domain

Once `capabilities.custom_domain.enabled` is `true`, set the domain your user wants, a subdomain such as `help.acme.example`:

```
curl -sS -X PUT "https://api.helpcenter.io/v1/custom-domain" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"domain": "help.acme.example"}'
```

The answer is `200 OK`:

```
{
  "status": "success",
  "custom_domain": {
    "domain": "help.acme.example",
    "status": "pending_dns",
    "dns_records": [
      {
        "type": "CNAME",
        "name": "help.acme.example",
        "value": "agent-qfcjyvj1.helpcenter.io"
      }
    ],
    "certificate": {
      "status": "waiting_for_dns"
    },
    "checks": {
      "dns_points_here": false,
      "certificate_issued": false,
      "certificate_serving": false
    },
    "failure": null,
    "last_checked_at": "2026-10-27T14:01:07+00:00"
  }
}
```

Give your user `dns_records` as it is: the record they add at their DNS provider. Ask for a plain CNAME record that isn't proxied: a record at the root of a domain, or one behind a proxy, never shows as pointing here. Then read `GET /v1/custom-domain` every five minutes or so. `status` moves from `pending_dns` through `verifying` and `issuing_certificate` to `live`, one step at a time, starting once the record appears. `failed` comes with a `failure` whose `message` says what to do.

- Setting the domain the help center already has changes nothing, so a retry is safe. Setting another one switches the current domain off at once.
- A help center may change its domain 4 times in a rolling month. The fifth change answers `429` with `custom_domain_limit_reached` and a `Retry-After` header, and changes nothing.
- A domain another help center already uses answers `409 Conflict` with `domain_taken`.
- `DELETE /v1/custom-domain` removes the domain.

Every field and status is in [Custom domain](https://developers.helpcenter.io/content/custom-domain-api).

### Keep the old help center's links working

Once `capabilities.redirects.enabled` is `true`, redirect each old path to the page that replaced it. A visitor who opens the old path gets a `301` to the new page, so this works once the old address reaches this help center: when your user's domain moves here.

```
curl -sS -X POST "https://api.helpcenter.io/v1/redirects" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"from": "/hc/en-us/articles/360001234-Change-your-plan", "to": "/en/content/change-your-plan"}'
```

The answer is `201 Created`, or `200 OK` with `"action": "updated"` when the path already had a redirect, which now points to the new page:

```
{
  "status": "success",
  "action": "created",
  "redirect": {
    "id": 1,
    "from": "/hc/en-us/articles/360001234-Change-your-plan",
    "to": "/en/content/change-your-plan",
    "created_at": "2026-10-27T14:00:57+00:00",
    "updated_at": "2026-10-27T14:00:57+00:00"
  }
}
```

- `from` is a path alone, without a host, query or fragment, and at most 191 characters. A path the help center answers itself can't be redirected and answers `422` with `source_not_redirectable`: a one-segment path such as `/pricing`, which always ends on the home page, and an address like `/categories`, `/search` or an existing article or category.
- `to` is a page of this help center that exists now, as a path (`/en/content/billing`) or a URL on its own address: a published, public article, a category, the home page, the category list or the contact page. Anything else answers `422`: `target_not_found` for a page that doesn't exist (publish the article first, and use its current address) and `external_target_forbidden` for another site, which only your user can add, in their dashboard.
- At most 3 redirects may follow one another, and none may loop: `too_many_hops` and `redirect_loop` refuse the redirect. Point each one at the final page.

`POST /v1/redirects/bulk` takes up to 200 as `{"redirects": [...]}` and answers `207` with one result per item, in order. One that fails doesn't stop the others:

```
{
  "status": "success",
  "results": [
    {
      "index": 0,
      "status": "created",
      "redirect": {
        "id": 2,
        "from": "/hc/en-us/categories/360000100-Billing",
        "to": "/category/304",
        "created_at": "2026-10-27T14:00:57+00:00",
        "updated_at": "2026-10-27T14:00:57+00:00"
      }
    },
    {
      "index": 1,
      "status": "error",
      "error": {
        "code": "target_not_found",
        "message": "The target is not a page of this help center right now. Point it at the current address of its home page, category list, contact page, a category, or a published public article in one of its languages."
      }
    },
    {
      "index": 2,
      "status": "error",
      "error": {
        "code": "external_target_forbidden",
        "message": "An AI agent's redirects stay on this help center: point it at one of its pages, as a path like /en/content/billing or a URL on its own address. A redirect to another site is the help center owner's to add, in the dashboard."
      }
    }
  ],
  "meta": {
    "total": 3,
    "created": 1,
    "updated": 0,
    "failed": 2
  }
}
```

`GET /v1/redirects` lists them, newest first (`search`, `page`, and `limit` up to 100, 50 by default), and `DELETE /v1/redirects/{id}` removes one. Only API keys can manage redirects. Every rule is in [Redirects](https://developers.helpcenter.io/content/redirects-api).

After an import, the importer has the whole map. `GET /v1/imports/{importId}/redirect-map` gives each old category's, section's and article's path in every language, with the path of its page here, 200 to a page (`?page=2`, up to `meta.total_pages`), in the shape the bulk endpoint takes. Here it is shortened to four entries:

```
{
  "status": "success",
  "import_id": 1,
  "source_host": "help.acme.example",
  "redirects": [
    {
      "from": "/hc/en-us/categories/100-Getting-started",
      "to": "/en/category/294-getting-started"
    },
    {
      "from": "/hc/en-us/categories/101-Billing",
      "to": "/en/category/295-billing"
    },
    {
      "from": "/hc/en-us/articles/1001",
      "to": "/en/content/welcome-to-acme"
    },
    {
      "from": "/hc/en-us/articles/1002",
      "to": "/en/content/set-up-billing"
    }
  ],
  "left_out": [],
  "meta": {
    "page": 1,
    "per_page": 200,
    "total_pages": 1,
    "items_count": 15
  }
}
```

Post each page's `{"redirects": [...]}` to `POST /v1/redirects/bulk` as it is. Posting it again re-points what is there and adds nothing, so read the map again before you apply it again: an old copy would undo a change someone made since. A long old path is given by its id alone (`.../articles/360001`), which forwards the address with any title after the id. `left_out` lists, on every page, each old address the map can't give, as `{"source_url", "reason"}`: `source_path_too_long`, `target_path_too_long` (shorten that article's slug), or `source_answered_here` (the help center answers that address itself). Tell your user which old links won't forward. Imported drafts can't be redirected to until they are published.

## What else your key can do

- **The design.** Your key can read and change the help center's design with the [Design API](https://developers.helpcenter.io/content/design-api), for example to match the old help center's colors, fonts and logo. It can't add, change or remove the design's scripts or HTML: those answer `403` with `agent_scripts_forbidden` and the changes that were refused, and the help center's team makes them in the Template Editor. Applying a theme whole changes its scripts too, so leave out the sections the message names. Its CSS may load files only from the help center: a `url()`, an `@font-face` source or a Hero background on another host answers `422` with `agent_css_url_forbidden`, naming the address. Use a relative path, a `data:` image, or an image you stored with `POST /v1/images`; for a brand font, pick the closest family on fonts.bunny.net and tell your user, who can upload the real one.
- **Nothing else.** Every other endpoint, `GET /v1/account` included, answers `403` with `agent_not_allowed`: before the claim, and for a key your user let you keep. `GET /v1/sites` works: it holds the help center's language code, and `published`, `false` until your user publishes it.

## Delete a help center you created

To start over, delete an unclaimed help center with its key:

```
curl -sS -X DELETE "https://api.helpcenter.io/v1/agent/help-centers/$SITE_ID" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json"
```

The answer is `200 OK` with `{"status": "success", "site_id": 375}`, and the key stops working at once. A `202 Accepted` with `"status": "pending"` means part of the deletion is retried automatically; nothing more is needed. A claimed help center answers `409` with `already_claimed`: it is your user's, and only they can delete it. A help center held by an open migration request answers `409` with `migration_in_progress`.

## Limits

| Limit | Value |
| --- | --- |
| Help centers created from one address | 1 an hour and 3 a day (`429` `limit_reached`, with `Retry-After`), an IPv6 address counting with the rest of its /64. Only creations that succeed count. |
| Help centers created over MCP | From ChatGPT, a pool shared by every ChatGPT agent. From any other client, 60 an hour and 300 open at once, across every agent that uses the MCP server's agent endpoint. |
| Requests to create a help center from one address | 10 a minute, refused ones included (`429`). |
| Unclaimed help centers open at once, everywhere | A platform-wide limit (`503` `capacity_reached`): try later, or ask for a migration. |
| Articles in an unclaimed help center | 500, drafts and articles in Trash included (`422` `limit_reached`; inside a bulk request, the item's `error`). |
| Categories in an unclaimed help center | 100, categories in Trash included (`422` `limit_reached`). |
| Images and files in an unclaimed help center | 500 MB (`422` `limit_reached` with `limit` `storage_bytes`; an import stops there with the same reason). |
| Life of an unclaimed help center | 7 days from creation; paused while a migration request holds it, then 7 days from the end of the request. |
| Your key after the claim | 30 days, if your user left **Let this AI agent finish setting up your help center** ticked, and past the first publish only if your user lets you keep editing then. |
| Preview link | 1 hour, 60 pages a minute. |
| Articles per bulk request | 50, and 20 bulk requests a minute from one address. |
| Import scans and runs | 10 scans and 10 runs a minute for each key. |
| Reading one source site | 2 scans or runs at once and 20,000 requests a day, across every help center importing from it (`422` `host_busy`, `host_daily_limit`). |
| Migration requests from one address | 3 a day (`429` `limit_reached`), an IPv6 address counting with the rest of its /64, and 20 attempts a minute. Up to 50 sources each. |
| Migration requests over MCP | 30 a day, across every agent that uses the MCP server's agent endpoint. |
| Reading a migration request's status | 60 times an hour; `retry_after` is 900 seconds. |
| Redirects per bulk request | 200. |
| Custom domain changes | 4 in a rolling month (`429` `custom_domain_limit_reached`). |

Every other request counts toward your key's usual limits: see [Rate limits](https://developers.helpcenter.io/content/rate-limits).

## Errors

| Status and code | What it means | What to do |
| --- | --- | --- |
| `401` `unauthorized` | No key, or a key that no longer works: you deleted the help center, it expired, your user didn't let you finish setting up or keep editing once they published it, or the 30 days after the claim are over. | Your part is done, or create a new help center. |
| `410` `expired` | The help center expired before anyone claimed it, and was deleted with its key. | Start again with step 1. |
| `403` `agent_not_allowed` | Not available to your key: the help center is unclaimed, or your user let you keep the key and this endpoint isn't one it serves. | Use the content and design endpoints, and after the claim the redirects and the custom domain. |
| `403` `capability_locked` | Redirects or the custom domain, before the owner has verified their email address and activated a plan. | Pass `next_step.instruction` on to your user. |
| `403` `agent_scripts_forbidden` | A design change would touch scripts or HTML. | Leave those out; the help center's team makes them. |
| `403` `insufficient_scope` | Your user turned off **Allow managing the custom domain** for your key. | Ask your user to turn it on again in Settings, under API keys. |
| `403` `custom_domain_not_in_plan` | The help center's plan doesn't include a custom domain. | Your user upgrades at `upgrade_url`. |
| `404` | Another help center's id, or one that doesn't exist. A migration request with a wrong status token answers the same. | Use your own `site_id` and token. |
| `409` `migration_in_progress` | A write while a migration request holds the help center. | Read only, and follow the request. |
| `409` `already_claimed` | Deleting, or asking for a migration of, a help center your user claimed. | It is theirs now. |
| `409` `domain_taken` | Another help center uses that domain. | Your user removes it there first, or contacts support. |
| `409` `import_running`, `import_cancelled`, `not_importable` | The import is running, was cancelled, or can't be read. | Wait for the run to end, or scan the address again. For `not_importable`, build it yourself or ask for a migration. |
| `422` `validation_error` | A field is missing or invalid; `errors` names each one. | Fix the request. |
| `422` `name_blocked` | The name contains a protected brand name. | Use your user's own product or company name. |
| `422` `limit_reached` | The help center is full (articles, categories, or images and files: `limit` says which), or an import doesn't fit (`room` says what is left). | Ask for a migration, or choose less. |
| `422` `needs_credentials`, `not_public`, `too_large`, `robots`, `credentials_not_accepted` | The importer can't read the source with your key. | Ask for a migration. |
| `422` `host_busy`, `host_daily_limit` | Other help centers are importing from that site right now, or it has had as many of the importer's requests today as one site gets. | Scan again in a few minutes, or after 00:00 UTC; or ask for a migration. |
| `422` `agent_css_url_forbidden` | A design change would load a file in CSS from another host. | Use a path on the help center, a `data:` image, or an image stored with `POST /v1/images`. |
| `422` `source_not_redirectable`, `target_not_found`, `external_target_forbidden`, `redirect_loop`, `too_many_hops` | A redirect that can't be saved. In a bulk request, in the item's `error`. | See "Keep the old help center's links working". |
| `429` `limit_reached` | A limit for your address or request, from the table above. | Wait `Retry-After` seconds, or ask for a migration. |
| `503` `capacity_reached`, `busy` | As many help centers are open as HelpCenter.io allows at once, or many are being created right now. On a migration request, `capacity_reached` refuses the whole request, and nothing is filed. | Try again later, or file the request with fewer sources that need a new help center; for `busy`, after `Retry-After` seconds. |

An error body has `"status": "error"`, a `message` and, except a `404`, a `code`. A validation error has `"status": "validation_error"` and `errors` by field, a `401` has `"status": "unauthorized"`, and a `429` from a per-minute limit has only `{"message": "Too Many Attempts."}`. See [Errors](https://developers.helpcenter.io/content/errors) for the shapes every endpoint shares.

## Related

- [The HelpCenter.io MCP server](https://developers.helpcenter.io/content/the-mcp-connector-for-ai-agents)
- [MCP tool reference](https://developers.helpcenter.io/content/mcp-tool-reference)
- [Articles](https://developers.helpcenter.io/content/articles-api)
- [Categories](https://developers.helpcenter.io/content/categories-api)
- [Imports](https://developers.helpcenter.io/content/imports-api)
- [Redirects](https://developers.helpcenter.io/content/redirects-api)
- [Custom domain](https://developers.helpcenter.io/content/custom-domain-api)
- [Import articles in bulk](https://developers.helpcenter.io/content/import-articles-in-bulk)
- [Rate limits](https://developers.helpcenter.io/content/rate-limits)
- [Errors](https://developers.helpcenter.io/content/errors)
