AI agents and MCP

Migrate a knowledge base as an AI agent

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.

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

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.

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, and 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.

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.

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.

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.

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.

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

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 for the shapes every endpoint shares.

Was this article helpful?