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
apikeyheader.Every request goes to
https://api.helpcenter.io/v1withAccept: application/json, and every request with a body also sendsContent-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, andhttps://api.helpcenter.io/openapi.jsondescribes 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 |
|
2. Create the categories |
|
3. Send the articles |
|
Import a supported help center |
|
4. Check what arrived |
|
5. Give your user the claim link | No tool: give your user |
Ask for a migration |
|
6. Finish setting up after the claim |
|
Delete a help center you created |
|
The key is an argument.
helpcenter_create_help_centerreturns the help center's key once, asapi_key. Every tool that acts on the help center requires it asagent_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 noagent_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_reachedorcapacity_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_imagesis 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 |
|---|---|
| Required. Your user's product or company, up to 100 characters. A name that contains a protected brand name answers |
| The knowledge base you are moving, |
| The help center's language, such as |
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_idis the article's address in the source, exactly as the source gives it, up to 191 characters. Sending the sameexternal_idagain updates that article instead of adding a second one: the result'sstatusis thenupdated. So a retry after a timeout or an error is always safe.published: truefor the articles that are live in the source. Leave it out for a draft.rehost_images: truecopies the source's images into the help center, so they keep working when the old knowledge base is gone.imagesin each result says how many were copied and which failed.content_format: "markdown"on an article whosecontentis Markdown. HTML is the default.categoriestakes the ids as JSON numbers. An id sent as a string, such as["292"], is refused withOne or more of the categories attached does not exist.Retry only the results whose
statusiserror. Each holds theexternal_idyou sent and anerrorwith acode, amessageand, for a validation error, theerrorsby field. Anerror.codeoflimit_reachedmeans 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-Afterseconds.
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"
}
}
importableisfalse, withplatform: "other"andplan: null, when the importer can't read the address. That is still a201: 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.categoriesare the source's top-level categories. Eacharticlescount includes everything nested under it, and its sections arrive as subcategories.plan.languagesare the source's languages.lands_inis 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 whoselands_inisnullcan't be imported.limitsays whether the chosen articles fit:roomis what is left of the 500,articles_to_importwhat the import would bring, andfitscompares them.A private or very large source answers
422with acode: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_largeorrobots. 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
422withhost_busy(try again in a few minutes) orhost_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": truewith the scan, to confirm that your user owns the source or may copy its content; without it the scan answers422withvalidation_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"
}
}
| Meaning |
|---|---|
| Scanned, not run yet. |
| Waiting to run. A large import also goes back to |
| Importing. |
| Everything you chose is in. |
|
|
| Cancelled with |
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.stateheldandclaim.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_urlor 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 |
|---|---|
| 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. |
| Required. A JSON array of 1 to 50 sources, one help center each. |
| Required. The knowledge base's address, |
| The name of the help center created for this source, up to 100 characters. |
| How many articles you counted, for the team's planning. |
| The source's main language, such as |
| Anything the team should know, up to 2,000 characters. |
| 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_centersis in the order of yoursources. A source without anagent_keygets a new unpublished help center, in itslanguage(English when you send none), and no key for you: here the wiki's help center is in German.status_tokenis shown only here. Keep it as safe as a password: it reads the request, and the request's claim link with it.handoff_urlis 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_byis 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:
| Meaning |
|---|---|
| Waiting to start. |
| Being migrated. |
| 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 |
| Done. |
| The team won't migrate it, or didn't take it up within 7 days: then |
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.
| What it means |
|---|---|
| An open migration request holds the help center. Follow the request. |
| Nobody has claimed it yet, or a claim waits for an email address to be verified. |
| Your user has claimed it and must verify their email address. |
| 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. |
| 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
429withcustom_domain_limit_reachedand aRetry-Afterheader, and changes nothing.A domain another help center already uses answers
409 Conflictwithdomain_taken.DELETE /v1/custom-domainremoves the domain.
Every field and status is in Custom domain.
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"
}
}
fromis 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 answers422withsource_not_redirectable: a one-segment path such as/pricing, which always ends on the home page, and an address like/categories,/searchor an existing article or category.tois 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 answers422:target_not_foundfor a page that doesn't exist (publish the article first, and use its current address) andexternal_target_forbiddenfor 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_hopsandredirect_looprefuse 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
403withagent_scripts_forbiddenand 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: aurl(), an@font-facesource or a Hero background on another host answers422withagent_css_url_forbidden, naming the address. Use a relative path, adata:image, or an image you stored withPOST /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/accountincluded, answers403withagent_not_allowed: before the claim, and for a key your user let you keep.GET /v1/sitesworks: it holds the help center's language code, andpublished,falseuntil 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 ( |
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 ( |
Unclaimed help centers open at once, everywhere | A platform-wide limit ( |
Articles in an unclaimed help center | 500, drafts and articles in Trash included ( |
Categories in an unclaimed help center | 100, categories in Trash included ( |
Images and files in an unclaimed help center | 500 MB ( |
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 ( |
Migration requests from one address | 3 a day ( |
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; |
Redirects per bulk request | 200. |
Custom domain changes | 4 in a rolling month ( |
Every other request counts toward your key's usual limits: see Rate limits.
Errors
Status and code | What it means | What to do |
|---|---|---|
| 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. |
| The help center expired before anyone claimed it, and was deleted with its key. | Start again with step 1. |
| 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. |
| Redirects or the custom domain, before the owner has verified their email address and activated a plan. | Pass |
| A design change would touch scripts or HTML. | Leave those out; the help center's team makes them. |
| 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. |
| The help center's plan doesn't include a custom domain. | Your user upgrades at |
| 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 |
| A write while a migration request holds the help center. | Read only, and follow the request. |
| Deleting, or asking for a migration of, a help center your user claimed. | It is theirs now. |
| Another help center uses that domain. | Your user removes it there first, or contacts support. |
| The import is running, was cancelled, or can't be read. | Wait for the run to end, or scan the address again. For |
| A field is missing or invalid; | Fix the request. |
| The name contains a protected brand name. | Use your user's own product or company name. |
| The help center is full (articles, categories, or images and files: | Ask for a migration, or choose less. |
| The importer can't read the source with your key. | Ask for a migration. |
| 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. |
| A design change would load a file in CSS from another host. | Use a path on the help center, a |
| A redirect that can't be saved. In a bulk request, in the item's | See "Keep the old help center's links working". |
| A limit for your address or request, from the table above. | Wait |
| As many help centers are open as HelpCenter.io allows at once, or many are being created right now. On a migration request, | Try again later, or file the request with fewer sources that need a new help center; for |
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.