# Imports

_Category: REST API reference_

An import brings a knowledge base from Zendesk Guide, Intercom, Document360, Help Scout Docs, Front Knowledge Base or HelpDocs into your help center. HelpCenter.io's importer reads the source, you choose what to bring, and it creates the categories, articles, languages and images. Afterward, its redirect map pairs every old address with the page that replaced it.

## Endpoints

| Method and path | What it does | Scope |
| --- | --- | --- |
| `POST /v1/imports` | Scan a help center | `content.write` |
| `PUT /v1/imports/{importId}` | Choose what to bring | `content.write` |
| `POST /v1/imports/{importId}/run` | Run an import | `content.write` |
| `GET /v1/imports/{importId}` | Follow an import | `content.write` |
| `DELETE /v1/imports/{importId}` | Cancel an import | `content.write` |
| `GET /v1/imports` | List imports | `content.write` |
| `GET /v1/imports/{importId}/redirect-map` | Get the redirect map | `content.write` |

Every route needs `content.write`, the reads included, because an import writes into the help center. Use a **Read & write** API key, or an OAuth access token with `content.write` whose person is an Owner or Admin of the help center (name the help center as shown in [Choose which help center a request acts on](https://developers.helpcenter.io/content/choosing-a-help-center)). A Read only key gets `403 Forbidden` with `insufficient_scope`, on `GET` too.

Scans and runs have limits of their own: 10 scans and 10 runs a minute for each credential, besides the 300 requests a minute that every request counts toward. Choosing and cancelling count toward the 120 writes a minute. See [Rate limits](https://developers.helpcenter.io/content/rate-limits).

## How an import works

1. **Scan.** Send the source's address. The importer recognizes the platform, reads the source while you wait, and answers with the import and its `plan`: the source's top-level categories, its languages, and how many articles and images it holds.
2. **Choose.** Pick categories and languages, and whether articles arrive as drafts or as they are at the source. This step is optional: until you choose, a run brings every category and every language that lands here, as drafts.
3. **Run.** The answer is `202 Accepted`. The import runs in the background.
4. **Follow.** Read the import until its `status` is `completed` or `failed`.
5. **Redirect.** Publish the articles you keep, then read the redirect map and post it to `POST /v1/redirects/bulk`, so the old help center's links lead to the new pages once its address points here.

Import one source at a time: scanning another address cancels a scan you have not run yet. Running an import again updates what it brought before and adds nothing twice.

### Sources

| Source | `platform` | What to send |
| --- | --- | --- |
| Zendesk Guide | `zendesk` | The help center's address. For a private help center, also `zendesk_email` and `zendesk_token`. |
| Intercom | `intercom` | The address of a public help center. |
| Document360 | `document360` | The address of a public knowledge base. |
| Help Scout Docs | `helpscout` | The address of a public Docs site. |
| Front Knowledge Base | `front` | The address of a public knowledge base. |
| HelpDocs | `helpdocs` | The site's address and `helpdocs_api_key`, the account's API key. |

Any other address is `other`: the importer can't read it by itself. HelpCenter.io's migration team can move such a knowledge base (see [How do I get a free migration?](https://self.helpcenter.io/content/how-do-i-get-a-free-migration-of-my-existing-help-center)), or you can send its articles yourself (see [Import articles in bulk](https://developers.helpcenter.io/content/import-articles-in-bulk)).

## The import object

| Field | Type | Description |
| --- | --- | --- |
| `id` | integer | The import's id. |
| `platform` | string | `zendesk`, `intercom`, `document360`, `helpscout`, `front`, `helpdocs` or `other`. |
| `importable` | boolean | `false` when the importer can't read the source (`platform` `other`). |
| `source_url` | string | The address you scanned, with `https://` added when you left out the scheme and any `#fragment` dropped. |
| `source_host` | string | Its host name. |
| `status` | string | Where the import stands. The values are under "Follow an import" below. |
| `agent_initiated` | boolean | `true` when an AI agent's key set it up or ran it last. |
| `plan` | object or null | What the scan found. `null` when `importable` is `false`. |
| `plan.articles` | integer | Articles in the source's main language. |
| `plan.categories` | array | The source's top-level categories, each with `id` (a string, such as `c100`), `name` and `articles`, which counts everything nested under it. Their sections and subcategories arrive as subcategories. |
| `plan.languages` | array | The source's languages, each with `code` (the source's own, such as `en-us`), `label`, `articles` and `lands_in`: the help center language it is written into. `lands_in` is `null` for a language that can't be imported, because HelpCenter.io doesn't support it, another of the source's languages already lands there, or an earlier run wrote it into a language since removed from the help center. |
| `plan.default_language` | string | The `code` of the source's main language. |
| `plan.images` | integer | Images the scan counted. For a large source, an estimate. |
| `selection` | object | What a run brings: `category_ids`, `languages`, `arrive_as` and `articles`, the main-language articles in the chosen categories. Until you choose, every category and every language that lands here. |
| `selection.arrive_as` | string or null | `draft` or `as_is`. `null` in the answer to a first scan, where it means `draft`. |
| `refusal` | string or null | Why a run would write nothing: set when the help center's main language changed after the import wrote into it. |
| `limit` | object or null | The 500 articles an AI agent's import into an unclaimed help center is held to: `max`, `room`, `articles_to_import` and `fits`. `null` for every other import. |
| `progress` | object or null | The last run's counts. `null` until the first run starts. |
| `imported_articles` | integer | Articles this import brought that still exist here. A translation shares its article. |
| `needs_review` | object | `count`, and `articles`: the first 50 imported articles flagged for review, each with `id`, `title` and `note`. |
| `error` | object or null | After a failed run: `message`, and `reason`, a code or `null`. |
| `started_at`, `finished_at` | string or null | When the last run started and ended, ISO 8601. |
| `created_at`, `updated_at` | string | ISO 8601, with an offset. |

Credentials are never returned.

## Scan a help center

Send the address readers open, such as the help center's home page, and confirm that you own the source or may copy its content. The scan reads the source during the request, for up to 40 seconds, so give your HTTP client at least a minute (with cURL, `--max-time 90`).

POST`/v1/imports`

Scan a help center on another platform and answer with the import and its plan. Waits while the source is read, up to 40 seconds.

| Field | Type | Description |
| --- | --- | --- |
| `url` | string | Required. The source's address, up to 2,048 characters. Send the address readers see: an `http://` address, or an alias that redirects to the help center, can come back as `other`. |
| `confirm_ownership` | boolean | Required. `true`: you own this help center or may copy its content. |
| `zendesk_email` | string | For a private Zendesk help center: the email address of a Zendesk agent, up to 255 characters. Send it with `zendesk_token`. |
| `zendesk_token` | string | That agent's Zendesk API token, up to 200 characters. |
| `helpdocs_api_key` | string | For HelpDocs: the account's API key, up to 200 characters. |

- **Credentials** are kept encrypted, used only when the source turns out to be the platform they belong to, never returned, and deleted when you cancel the import.
- **A new import** answers `201 Created`. **An address you scanned before** answers `200 OK` with the same import, unless that import was cancelled before it brought anything, or handed to the migration team: then the scan makes a new one. One that is `scanned`, or cancelled after it brought something, is scanned again: its plan is refreshed, and its choice of categories and languages goes back to everything (`arrive_as` stays). One that is scheduled, queued, running, completed or failed comes back as it is, without a new scan; send new Zendesk or HelpDocs credentials with it to replace the ones it holds.
- **Scanning another address** cancels every other import that is still `scanned`, not run yet. Choosing or running a cancelled import answers `409 Conflict` with `import_cancelled`.
- **An address the importer can't read** is not an error: the answer is `201` with `importable: false`, `platform: "other"` and `plan: null`. Check `importable` before you go on. Running such an import answers `409` with `not_importable`. An address is read as one of the six 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; any other address is `other`, whatever its pages look like.

A source the importer recognizes but can't read, and an address it refuses, answer `422 Unprocessable Entity`. The body has `status` `error`, a `code`, the `platform` (`null` when the address was refused before the platform was known) and a `message` written for a person. In the messages below, `{host}` stands for the source's host name.

| `code` | When | Message, for example |
| --- | --- | --- |
| `needs_credentials` | A private Zendesk help center, a HelpDocs site without `helpdocs_api_key`, or credentials the source refused. | `This help center is private. Add a Zendesk API token to import it.` or `Add your HelpDocs API key to import this help center.` |
| `not_public` | The knowledge base isn't public. | `This knowledge base is not public, so we cannot read it. Our migration team can move it for you.` |
| `too_large` | The source is larger than the importer brings by itself, or too slow to scan in time. | `{host} took longer to scan than we can wait here: it is very large, or slow to answer. Our migration team can move it for you instead, free.` |
| `robots` | The source's robots.txt asks crawlers not to read it, or to wait longer between pages than a scan can. | `This knowledge base's robots.txt asks crawlers not to read {path}. Our migration team can move it for you.` |
| `remote_error` | The source answered in a way the importer can't use, or the help center lives at another address. | `This Intercom help center lives at {url}. Paste that address to import it.` |
| `unresolvable` | The host name has no DNS record. | `We could not find {host}. Check the address and try again.` |
| `unreachable`, `transport` | The source stopped answering. | `We could not reach {host}. Check the address and try again.` or `Something went wrong while reading {host}.` |
| `bad_redirect`, `too_many_redirects` | The source redirects nowhere, or too many times. | `{host} redirected too many times.` |
| `ip_in_blocked_range`, `resolves_to_blocked_range` | The address isn't on the public internet. | `{host} is not a public address.` |
| `scheme_not_allowed`, `port_not_allowed`, `credentials_in_url`, `unparseable_url` | The address itself can't be fetched. | `Only addresses on the standard web ports are fetched.` or `That does not look like a web address.` |
| `host_busy` | Two scans or runs are already reading that site, across every help center importing from it. | `Other help centers are importing from {host} right now, and we read it for at most 2 at once. Try again in a few minutes.` |
| `host_daily_limit` | The site has had 20,000 of the importer's requests today, across every help center importing from it. | `We have read {host} as often today as we read one site in a day. Try again after 00:00 UTC, or our migration team can move it for you instead, free.` |

## Choose what to bring

Send the category ids and language codes from the import's `plan`, as strings, and how the articles should arrive. Choosing starts nothing.

PUT`/v1/imports/{importId}`

Choose the categories and languages to bring, and whether the articles arrive as drafts. Starts nothing.

| Field | Type | Description |
| --- | --- | --- |
| `category_ids` | array of strings | Required. Ids from `plan.categories`, each as a JSON string: a number is refused. Each brings its category and everything under it. |
| `languages` | array of strings | Required. Codes from `plan.languages` whose `lands_in` is not `null`. The source's main language is always imported, even when you leave it out. |
| `arrive_as` | string | `draft` (the default): every article arrives as a draft. `as_is`: articles that are published at the source and visible to everyone there arrive published, and the rest as drafts. Left out, it stays as it was. |

- Send `category_ids` and `languages` on every `PUT`. An empty list answers `422` with `Choose at least one category to import.` or `Choose at least one language to import.`; a value from outside the plan, with `Choose categories from the import's plan (plan.categories[].id).` or `Choose languages from the import's plan that land here (plan.languages[] with a lands_in).`
- For a Front or Intercom source, a choice with more article pages than one import reads, counting each language, answers `422` on `languages`: `What you chose comes to {n} articles, counting each language, more than the {limit} we import ourselves. Leave a language or a category out.`
- An import with `importable: false` has no plan to choose from, so every `PUT` answers `422`.
- The `status` doesn't change. While the import is queued or running, the answer is `409` with `import_running`; for a cancelled import, `409` with `import_cancelled`. You can choose again after a run, then run it again.

## Run an import

Start the import with what is chosen. It runs in the background: the answer is `202 Accepted` with the import, its `status` now `queued`.

POST`/v1/imports/{importId}/run`

Start the import with what is chosen. The run happens in the background: follow it with GET /v1/imports/{importId}.

- Running a `completed` or `failed` import runs it again: an article it brought before is updated, never added a second time, and `progress` then describes the new run.
- A run from the API runs once. A recurring sync is set up in the dashboard only.
- An import that is queued or running answers `409` with `import_running`, so a second click does nothing. A cancelled one answers `409` with `import_cancelled`: scan its address again to set it up anew. One with `importable: false` answers `409` with `not_importable`.
- When the help center's main language changed after the import wrote into it, the answer is `409` with `main_language_changed` and the message in `refusal`: `The articles from {host} were imported into {language}, and your main language is now {language}, so nothing more will be imported until you switch it back in Settings, under Localization.`

## Follow an import

Read the import every 10 seconds or so until `status` is `completed` or `failed`. Each read counts toward the 300 requests a minute only.

GET`/v1/imports/{importId}`

Get an import: its plan, what is chosen, how the last run went, and the articles that wait for review.

| `status` | Meaning |
| --- | --- |
| `scanned` | Scanned, not run yet. |
| `scheduled` | Set up in the dashboard to run later. Running it from the API starts it now. |
| `queued` | Waiting to run. A large import also goes back to `queued` for a few seconds between two runs, and an import waits here while its source site is busy with other help centers' imports or has had its requests for the day: keep following it. |
| `running` | Importing. |
| `completed` | Everything you chose is in. |
| `failed` | The run stopped. `error` says why. What arrived stays; run the import again to retry. |
| `cancelled` | Cancelled with `DELETE`, or by a scan of another address before it ran. |
| `assisted` | Handed to HelpCenter.io's migration team from the dashboard. It doesn't run from the API. |

In a shell, with `jq`:

```
while :; do
  STATUS=$(curl -sS "https://api.helpcenter.io/v1/imports/$IMPORT_ID" \
    -H "apikey: $HELPCENTER_API_KEY" -H "Accept: application/json" | jq -r .import.status)
  echo "$STATUS"
  case "$STATUS" in completed|failed|cancelled) break ;; esac
  sleep 10
done
```

### Progress

| Field | Description |
| --- | --- |
| `total` | The chosen articles, as the scan counted them in the main language. |
| `done` | Main-language articles handled so far. It is saved every 10 articles, so it moves in steps, and it never goes back. Translations don't count in it. |
| `created`, `updated` | Articles created, and articles brought before that were updated. |
| `conflicts` | Articles that changed both at the source and here since the last run. Your version is kept. |
| `removed` | Articles brought before that are no longer at the source. They stay here. |
| `images`, `image_failures` | Images copied into the help center, and images that could not be copied. |

An article is flagged for review, and listed in `needs_review` with a `note`, when it changed both at the source and here, when it is no longer at the source, when only some readers could see it at the source, or when the source holds something the importer doesn't bring, such as files attached to a Front article.

A failed run sets `error.message` and `error.reason`. `reason` is a scan code from "Scan a help center" above, such as `needs_credentials` or `too_large`, `main_language_changed`, `limit_reached`, `not_on_platform` (the source's address no longer points at its platform), or `null` with `Something went wrong while importing. Our team has been told; you can try again.` or `The import stopped before it finished. You can try again.`

## What an import brings

- **Categories:** each chosen top-level category and everything under it, in the source's order and hierarchy, visible to everybody. A category you deleted here since an earlier run is not brought back, and one you renamed keeps its name.
- **Articles:** filed in their categories, as drafts or published as `arrive_as` says, with links between imported articles rewritten to their new addresses. An article that only some readers could see at the source arrives private and as a draft. Imported articles have no `external_id`.
- **Languages:** the source's main language lands in the help center's main language. Every other chosen language lands in the language its `lands_in` names, which is added to the help center when it doesn't have it yet. A translation goes into the same article as its original, and is published only with `as_is`, when it is published at the source.
- **Images:** copied into the help center. An image that can't be copied keeps its original address and counts in `progress.image_failures`.
- **Running again** updates what the import brought before and adds nothing twice. An article that changed both at the source and here since the last run keeps your version and is flagged; an article no longer at the source is flagged and never deleted.

The importer never deletes anything in your help center, and it doesn't bring a source's comments, users or analytics.

## Cancel an import

Cancel an import that is not running. It never runs again, any credentials it held are deleted, and everything it already brought stays. Cancelling a cancelled import answers `200` again.

DELETE`/v1/imports/{importId}`

Cancel an import that is not running. Nothing it already brought in is deleted.

An import that is queued or running answers `409` with `import_running`. Wait for the run to end, then cancel it. To import the same address again later, scan it: what the cancelled import brought is recognized, so nothing arrives twice.

## List imports

The help center's imports, newest first, in every status, including imports a person set up in the dashboard. This is how you find a source someone connected there, to follow it and run it here.

GET`/v1/imports`

List the help center's imports, newest first: up to 50, in every status, imports set up in the dashboard included.

The list holds the 50 newest imports and has no paging. An older import is still readable by its id.

## Get the redirect map

The map pairs each old address the import brought, every category, section and article in every language, with the path of its page here. Use it to keep the old help center's links working once its address points to your help center (see [Custom domain](https://developers.helpcenter.io/content/custom-domain-api)).

GET`/v1/imports/{importId}/redirect-map`

Get each old address the import mapped, with the path of its page here, 200 to a page, in the shape POST /v1/redirects/bulk takes.

- **Pages of 200.** Each page holds as many entries as one `POST /v1/redirects/bulk` takes. Read `?page=2` and on, up to `meta.total_pages`; `meta.items_count` counts the entries on every page. A page past the last has an empty `redirects`.
- **In the bulk endpoint's shape.** Each entry is `{"from", "to"}`: `from` is the old address's path, `to` the page's current path here.
- **A long old path** is given by its id alone where the source's addresses allow it, such as `/hc/en-us/articles/360001`, which forwards that address with any title after the id.
- **`left_out`** lists, whole on every page, each old address the map can't give, as `{"source_url", "reason"}`, where `source_url` is the full old address. `reason` is `source_path_too_long` (no form of the old path fits in a redirect), `target_path_too_long` (the page's address here is too long: shorten that article's slug) or `source_answered_here` (the help center answers that address itself, so a redirect from it would never be used). Pages deleted here since the import are in neither list.
- **Any import, any time.** The map has no status check: it gives what is mapped so far, which is nothing for an import that hasn't run.

Post a page as it is: the bulk endpoint reads its `redirects` and ignores the other fields. A page with no entries answers `422`, so skip it. Any other page answers `207` whatever its entries did, so read `meta.failed`.

```
curl -sS "https://api.helpcenter.io/v1/imports/$IMPORT_ID/redirect-map?page=1" \
  -H "apikey: $HELPCENTER_API_KEY" -H "Accept: application/json" \
| curl -sS -X POST "https://api.helpcenter.io/v1/redirects/bulk" \
  -H "apikey: $HELPCENTER_API_KEY" -H "Accept: application/json" \
  -H "Content-Type: application/json" --data-binary @-
```

Posting the map again re-points what is there and adds nothing, so read it again before you apply it again: an old copy would undo a change made since. A redirect can lead only to a page that exists now: an entry whose article is still a draft, or private, fails with `target_not_found` in the bulk answer while the rest of the page is saved. Publish those articles, read the map again and post it again. Every rule for redirects is in [Redirects](https://developers.helpcenter.io/content/redirects-api).

## With an AI agent's key

On a help center an AI agent created and nobody has claimed yet, the agent's key uses these endpoints with a few differences. It sends no `confirm_ownership`, because the person who claims the help center confirms instead, and no credentials (`422 credentials_not_accepted`): a private source answers `422 needs_credentials`, and its user connects it from the dashboard after the claim. A HelpDocs site is read from its public pages. Its imports carry a `limit` object, and a choice or a run that doesn't fit in the 500 articles the help center may hold answers `422 limit_reached` and saves nothing. A run also stops with `limit_reached` once the help center's images and files reach 500 MB. While a migration request holds the help center, its writes answer `409 migration_in_progress`. After the claim, a key its user let finish setting up sends `confirm_ownership: true` like any other key, and still sends no credentials. The whole flow is in [Migrate a knowledge base as an AI agent](https://developers.helpcenter.io/content/migrate-a-knowledge-base-as-an-ai-agent).

## Errors

| Status | Code or message | When |
| --- | --- | --- |
| `401` | `"status": "unauthorized"` | No key or token, or one the API doesn't accept. |
| `403` | `insufficient_scope` | A Read only key, or an OAuth token without `content.write`. |
| `403` | `You are not allowed to import content into this help center with this credential. It needs the content.write scope and, for an OAuth token, a person who administers the help center.` | An OAuth token whose person writes content but isn't an Owner or Admin of the help center, such as an Editor. |
| `403` | `role_forbidden` | An OAuth token whose person's role doesn't write content, such as a Viewer or Translator. `role` names it. |
| `404` | `No import with this id on this help center.` | An id that doesn't exist or belongs to another help center. |
| `409` | `import_running` | Choosing, running or cancelling while the import is queued or running. |
| `409` | `import_cancelled` | Choosing or running a cancelled import, or one handed to the migration team. |
| `409` | `not_importable` | Running an import whose `importable` is `false`. |
| `409` | `main_language_changed` | Running after the help center's main language changed. |
| `422` | `validation_error` | A field is missing or invalid. `errors` names each one. |
| `422` | A scan code, such as `needs_credentials` or `not_public` | The scan can't read the source. See "Scan a help center" above. |
| `429` | `{"message": "Too Many Attempts."}` | An 11th scan or run in a minute, or another rate limit. Wait `Retry-After` seconds. |

See [Errors](https://developers.helpcenter.io/content/errors) for the shapes every endpoint shares.

## Related

- [Migrate a knowledge base as an AI agent](https://developers.helpcenter.io/content/migrate-a-knowledge-base-as-an-ai-agent)
- [Redirects](https://developers.helpcenter.io/content/redirects-api)
- [Import articles in bulk](https://developers.helpcenter.io/content/import-articles-in-bulk)
- [Articles](https://developers.helpcenter.io/content/articles-api)
