# Errors

_Category: Getting started_

When a request fails, the HTTP status says what kind of problem it is. Most error bodies add a message written for people, and many add a machine-readable `code`. This page lists every error shape and every code the API returns, what each one means and what to do about it, and ends with the problems developers run into most.

## How to handle an error

1. **Check the status code first.** A few errors aren't JSON, so look at the status before you parse the body.
2. **Branch on `code`** when the body has one. Change sets use `reason` instead.
3. **Show `message` to people, but don't parse it.** Messages explain the problem and can change.
4. **Send `Accept: application/json`** with every request. It turns most errors that would be HTML pages into JSON, and stops validation errors from becoming redirects.

For example, creating an article with a **Read only** key:

```
curl https://api.helpcenter.io/v1/articles \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "title": { "en": "Shipping times" } }'
```

The API answers `403 Forbidden`:

```
{
  "status": "error",
  "code": "insufficient_scope",
  "message": "This action requires the content.write scope."
}
```

## Error shapes

| Body | Status | Where you meet it |
| --- | --- | --- |
| `{"status": "unauthorized"}` | 401 | The request has no key or token, or one the API doesn't accept. |
| `{"status": "error", "message": …}` | 400, 401, 403, 404, 409, 422, 500 | Most errors from an endpoint, such as `Article not found.`, and an image that couldn't be stored (500). |
| `{"status": "error", "code": …, "message": …}` | 400, 402, 403, 404, 409, 410, 412, 422, 429, 503 | Errors with a machine-readable code, sometimes with more fields. See Error codes below. |
| `{"status": "error", "reason": …, "message": …}` | 402, 409 | Change sets. |
| `{"status": "validation_error", "message": …, "errors": {…}}` | 422 | Validation on most endpoints. |
| `{"status": "validation_error", "errors": {…}}` | 400 | Validation on `GET /v1/articles`. |
| `{"<field>": […]}` | 400 | Validation on `POST /v1/articles`. |
| `{"message": …, "errors": {…}}` | 422 | Validation on `GET /v1/categories`, `GET /v1/images` and `PATCH /v1/articles/{id}`, when you send `Accept: application/json`. Without it, a `302` redirect. |
| `{"message": …}` | 405, 429, 500 | General errors, when you send `Accept: application/json`. Without it, an HTML page. |
| An HTML page | 404, 413, 429 | An unknown path; an unknown id on `PATCH` or `DELETE /v1/articles/{id}`, `PATCH` or `DELETE /v1/categories/{id}` and `PUT /v1/categories/{id}/articles/order`; a request body over 32 MB; the edge rate limit. |

## Validation errors

Validation errors list the problems by field: every key of `errors` is a field you sent, and every value a list of messages. The envelope around them depends on the endpoint. Most endpoints answer `422 Unprocessable Entity`, as here for a category name sent as a string:

```
{
  "status": "validation_error",
  "message": "The request could not be accepted.",
  "errors": {
    "name": [
      "The name must be a {locale: text} map — for example {\"en\": \"Billing\"}."
    ]
  }
}
```

`GET /v1/articles` answers `400 Bad Request` without a `message`, here for `limit=500`:

```
{
  "status": "validation_error",
  "errors": {
    "limit": ["The limit field must not be greater than 100."]
  }
}
```

`POST /v1/articles` answers `400` with the fields at the top level, here for an article without a title:

```
{"title": ["The title field is required."]}
```

`GET /v1/categories`, `GET /v1/images` and `PATCH /v1/articles/{id}` answer `422` without a `status`, and only when you send `Accept: application/json`. Without that header they redirect to `https://api.helpcenter.io` with a `302`. Here `GET /v1/categories?limit=500`:

```
{
  "message": "The limit field must not be greater than 100.",
  "errors": {
    "limit": ["The limit field must not be greater than 100."]
  }
}
```

Bulk requests validate every item on its own, inside a `207` response. Read `results`: the top-level `status` is `success` even when every item failed.

```
{
  "status": "success",
  "results": [
    {
      "index": 0,
      "status": "error",
      "external_id": null,
      "error": {
        "code": "validation_error",
        "message": "The article could not be accepted.",
        "errors": {"title": ["The title field must be an array."]}
      }
    },
    {
      "index": 1,
      "status": "error",
      "error": {
        "code": "invalid_item",
        "message": "Each item must be an article object."
      }
    },
    {
      "index": 2,
      "status": "error",
      "external_id": null,
      "error": {
        "code": "invalid_argument",
        "message": "Unsupported language key \"fr\" provided."
      }
    }
  ],
  "meta": {"total": 3, "created": 0, "updated": 0, "failed": 3}
}
```

## Status codes

| Status | What it means | What to do |
| --- | --- | --- |
| `302 Found` | Validation failed on `GET /v1/categories`, `GET /v1/images` or `PATCH /v1/articles/{id}`, and the request had no `Accept: application/json`. | Send the header. Don't follow redirects from the API: the target is a welcome message, not your result. |
| `400 Bad Request` | Validation on `GET` and `POST /v1/articles`, a language code your help center doesn't have (`Unsupported language key "fr" provided.`), or `site_required`. | Fix the request. |
| `401 Unauthorized` | No credential, or one the API doesn't accept: a missing, mistyped or deleted key, the key of a person whose HelpCenter.io account was deleted, or an OAuth token that expired or was revoked. `/v1/change-sets` also answers `401` with `Unknown API key.` to an OAuth token. | Check the `apikey` header, or refresh the token. Use an API key for change sets. |
| `402 Payment Required` | The help center doesn't have this feature: `plan_upgrade_required` for requests through the MCP server, `CHANGE_SETS_UNAVAILABLE` for change sets. | Change the plan, or for change sets on Catalyst, request early-preview access. Retrying doesn't help. |
| `403 Forbidden` | The credential is valid but may not do this: `insufficient_scope`, `site_forbidden`, `notes_forbidden`, `design_forbidden`, `STAGING_UNAVAILABLE`, `upload_url_invalid` or `upload_url_revoked`. Also asking for commenters' email addresses with a Read only key. | See the code in the table below. |
| `404 Not Found` | Nothing with that id exists on the help center your credential acts on. That includes ids from other help centers and items in the Trash. A key whose help center was deleted gets `The site this API key belongs to is no longer available.` The design endpoints answer with a code, listed below. | Check the id, and which help center your key belongs to: `GET /v1/sites` tells you. |
| `405 Method Not Allowed` | The path exists, but not with this method. | Check the method in the reference. |
| `409 Conflict` | The request clashes with the current state: `category_not_empty`, `STALE_ETAG`, `BASE_CHANGED`, `template_migration_required`, `nothing_to_publish`, a change set `reason`, an `external_id` that another article holds or that another request is importing, or an image that articles still use. | Read the body: it says what clashed, and often what to send instead. |
| `410 Gone` | An upload URL expired or was already used. | Create another with `POST /v1/images/uploads`. |
| `412 Precondition Failed` | `stale_draft`: the design draft changed since the `etag` you sent, so nothing changed. | Read the draft again, make your change on top of it, and retry with the new `etag`. |
| `413 Payload Too Large` | The request body is over 32 MB. | Send fewer or smaller files per request. |
| `422 Unprocessable Entity` | Validation failed, or the request is valid but can't be carried out, such as `NOT_PUBLISHED` or `template_invalid`. | Fix the fields in `errors`, or follow the message. |
| `429 Too Many Requests` | You hit a rate limit. | Wait for `Retry-After` seconds. See [Rate limits](https://developers.helpcenter.io/content/rate-limits). |
| `500 Internal Server Error` | Something failed on HelpCenter.io's side. With `Accept: application/json`, the body is `{"message": "Server Error"}`. | Retry later. If it keeps happening, [contact support](https://self.helpcenter.io/content/contact-support) with the method, path, time and body of the request, never your key. |
| `503 Service Unavailable` | `analytics_unavailable`: the analytics are temporarily unavailable. | Retry after `Retry-After` seconds (300). |

### 401, 403 or 404?

`401` means the API doesn't know who you are. `403` means it knows, but your credential may not do this. `404` means there is nothing with that id on the help center your credential acts on: an id from another help center always answers `404`, never `403`, so an error never confirms that an id exists somewhere else.

```
{"status": "error", "message": "Article not found."}
```

Some `404` answers are HTML pages, whatever you send in `Accept`: an unknown path, and an unknown id on `PATCH` or `DELETE /v1/articles/{id}`, `PATCH` or `DELETE /v1/categories/{id}` and `PUT /v1/categories/{id}/articles/order`. Check the status code before you parse the body.

## Error codes

These codes appear in the `code` field of REST API errors. The last four, marked "reason", appear in the `reason` field of change set errors.

| Code | Status | What it means | What to do |
| --- | --- | --- | --- |
| `insufficient_scope` | 403 | The credential lacks the scope this endpoint needs. The message names it: `This action requires the content.write scope.` | Use a **Read & write** key (for team notes, one with team notes access; for the design, one with design access), or ask the user to approve that scope for your app. See [Scopes](https://developers.helpcenter.io/content/scopes). |
| `site_required` | 400 | An OAuth token reaches more than one help center, and the request didn't name one. `sites` lists the ids it can reach. An empty `sites` list means it reaches none. | Send `X-HCio-Site` or `?site=` with one of the ids. An empty list has two causes. The person's account owns no help center: connect your app from an account that owns the help center, or use an API key from that help center. Or every help center they shared has since been deleted: connect again and share one. |
| `site_forbidden` | 403 | The help center you named isn't one this token reaches, or its id isn't a number. | Use an id from `GET /v1/sites`. |
| `plan_upgrade_required` | 402 | A request through the MCP server for a help center that isn't on Growth or Catalyst. Adds `feature`, `upgrade_url` and `plans`. | Change the plan at `upgrade_url`. Calls you make to the REST API directly with an API key aren't affected. |
| `notes_forbidden` | 403 | The person behind the credential can no longer see team notes on this help center, which takes the Owner, Admin, Editor or Translator role. | Use a credential of someone who has that access. |
| `design_forbidden` | 403 | The person behind the credential can no longer edit this help center's design in the dashboard, which takes the Owner, Admin or Editor role. | Use a credential of someone who has that access. See [Design API](https://developers.helpcenter.io/content/design-api). |
| `analytics_rate_limited` | 429 | More than 6 analytics requests in a minute with this credential. | Wait for `Retry-After` seconds, and store what you read: most figures change once a day. |
| `analytics_unavailable` | 503 | The analytics are temporarily unavailable. Comes with `Retry-After: 300`. | Try again in five minutes. |
| `category_not_empty` | 409 | You are deleting a category that still holds articles, drafts included, or child categories. Adds `articles_count` and `children_count`. | Send `on_orphan=uncategorize` or `on_orphan=reparent`. |
| `upload_url_invalid` | 403 | The upload URL was changed, or isn't known. | Use the exact `upload_url`, or create another. |
| `upload_url_revoked` | 403 | The credential that created the upload URL can no longer upload images to this help center. | Create another with a credential that can. |
| `upload_url_expired` | 410 | The upload URL is past its `expires_at`, 15 minutes after it was created. | Create another. |
| `upload_url_used` | 410 | The upload URL was already used. Each one takes a single upload. | Create another. |
| `STAGING_UNAVAILABLE` | 403 | Staged changes aren't available on this help center: it isn't on Catalyst, or doesn't have early-preview access yet. | Update the live article with `PATCH /v1/articles/{id}`. On Catalyst, request access: in the dashboard, click **Change sets**, then **Request access**. |
| `NOT_PUBLISHED` | 422 | You staged changes on an article that isn't published, so there is no live version to keep them apart from. | Update the article with `PATCH /v1/articles/{id}`. |
| `ARTICLE_UNPUBLISHED` | 422 | You staged changes on an article that was unpublished after changes were staged. Those staged changes are kept. | Publish the article again, or update it with `PATCH /v1/articles/{id}`. |
| `STALE_ETAG` | 409 | The staged changes changed since you read them. Adds `scope` and the current `etag` or `shared_etag`. | Read them again, then retry with the new value. |
| `BASE_CHANGED` | 409 | The live article changed after you staged your edits. Adds `locale`. | Read the article again, or publish with `"force": true` to overwrite the live version. |
| `CHANGE_SETS_UNAVAILABLE` | 422 | You sent `change_set_id` while staging changes. | Leave it out. Add the article to a change set with `POST /v1/change-sets/{id}/items`. |
| `template_migration_required` | 409 | The help center doesn't use the template editor yet, so the design API can't reach it. Adds `customizer_url`. | Ask the owner to switch it to the template editor, at `customizer_url`. |
| `nothing_to_publish` | 409 | You published a design draft that is the same as the live design. | Change the draft first. |
| `stale_draft` | 412 | The design draft changed since the `etag` you sent. Adds the current `etag`. | Read the draft again, make your change on top of it, and retry. |
| `template_invalid` | 422 | An operation in a design batch couldn't be applied, so nothing was saved. `errors` lists each problem with its `operation`, `op`, `path` and `message`. | Fix the operation the error names, and send the batch again. |
| `unknown_theme` | 404 | No theme with that key. The message lists the keys. | Use a key from `GET /v1/template/themes`. |
| `component_not_found`, `not_found` | 404 | You read a component, script or custom CSS block that isn't in the design draft. | Read the current ids with `GET /v1/template`. |
| `version_not_found` | 404 | No published version of the design with that id on this help center. | Use an id from `GET /v1/template/versions`. |
| `CHANGE_SETS_UNAVAILABLE` (reason) | 402 | Change sets aren't available on this help center: it isn't on Catalyst, or doesn't have early-preview access yet. | Change the plan, or on Catalyst request access as for `STAGING_UNAVAILABLE`. Retrying doesn't help. |
| `CHANGE_SET_FULL` (reason) | 409 | The change set already holds 500 items. Items added before the limit stay added. | Put the rest in a second change set. |
| `TOO_MANY_OPEN_CHANGE_SETS` (reason) | 409 | The help center already has 25 open change sets. | Publish or delete one first. |
| `CHANGE_SET_NOT_OPEN` (reason) | 409 | The change set is being published, or was published, so its contents can't change. | Create another change set. |

For example, deleting a category that still holds an article:

```
{
  "status": "error",
  "code": "category_not_empty",
  "message": "This category still holds content. Re-send with ?on_orphan=uncategorize or ?on_orphan=reparent to say where it should go.",
  "articles_count": 1,
  "children_count": 0
}
```

### Codes inside results

Bulk responses report each failed item's code in `results[].error.code`. Change sets report their items' problems with a `reason`.

| Where | Code | What it means |
| --- | --- | --- |
| `POST /v1/articles/bulk` | `invalid_item` | The item isn't an article object. |
| `POST /v1/articles/bulk` | `validation_error` | The item failed validation. `error.errors` lists the problems by field. |
| `POST /v1/articles/bulk` | `invalid_argument` | The item uses a language code your help center doesn't have. |
| `POST /v1/articles/bulk` | `external_id_busy` | Another request is importing the same `external_id`. Retry the item shortly. |
| `POST /v1/articles/bulk` | `persist_failed` | The article couldn't be saved. |
| `POST /v1/images/bulk` | `storage_failed` | The image couldn't be stored. Retry it. |
| `POST /v1/images/bulk` | `import_failed` | The image at the `source_url` was refused or couldn't be downloaded. The message says why. |
| Change set `readiness.blockers[].reason` | `conflict`, `article_gone`, `category_gone` | An item can't be published: the live article changed after staging, or the article or category was deleted. The change set can't publish until you fix or remove it. |
| Change set `readiness.warnings[].reason` | `already_applied`, `locale_gap`, `staged_not_added` | Worth a look, but publishing isn't blocked. |
| Change set `rejected[].reason`, when you add items | `not_found` | No article or category with that id on the help center. A published article with nothing staged is rejected with a sentence instead. |

## Errors outside the REST API

The other surfaces answer in their own formats.

### OAuth

The authorization server on `https://helpcenter.io` answers with `error` and `error_description`, as OAuth 2.1 defines. See [OAuth 2.1 for apps and AI clients](https://developers.helpcenter.io/content/oauth).

| `error` | Status | When |
| --- | --- | --- |
| `invalid_request` | 400, 401, 404 | A public client sent no `code_challenge`, or an authorization code was used, expired or issued to another client (400). A refresh token that is invalid, expired or revoked (401). Dynamic client registration switched off (404). |
| `invalid_client` | 401 | Client authentication failed, the client isn't known, or the `redirect_uri` isn't one the client registered. |
| `invalid_grant` | 400 | The `code_verifier` doesn't match the PKCE challenge. |
| `invalid_scope` | Redirect | The authorization asked for a scope that doesn't exist. |
| `unsupported_grant_type` | 400 | A grant other than `authorization_code` or `refresh_token`. |
| `access_denied` | Redirect | The person clicked **Deny** on the consent screen. |
| `invalid_target` | 400 | A `resource` that isn't allowed, or that this grant didn't include. |
| `invalid_redirect_uri` | 400 | Client registration with missing or invalid `redirect_uris`. |
| `invalid_client_metadata` | 400 | Client registration with an unsupported `grant_types` or `token_endpoint_auth_method`. |
| `temporarily_unavailable` | 429 | More than 10 client registration requests in an hour from one IP address, refused ones included. |

`GET https://helpcenter.io/oauth/userinfo` answers a bad token with `401` and `{"error": {"code": "invalid_token", "message": …, "status": 401}}`.

### MCP server

`https://mcp.helpcenter.io` refuses a request before any tool runs with an HTTP status, and for a credential problem also a `WWW-Authenticate` header. Errors from the API behind a tool, such as `plan_upgrade_required`, reach the agent as the tool's result, with an explanation. See [Connect an AI assistant to your help center](https://developers.helpcenter.io/content/connect-an-ai-assistant-to-your-help-center).

| Status | When |
| --- | --- |
| `401`, JSON-RPC error `-32600` | No credential: sign in with OAuth, or send an API key as `Authorization: Bearer`. |
| `401`, `error="invalid_token"` | The OAuth token was rejected, for example because it expired or wasn't issued for `https://mcp.helpcenter.io`. |
| `403`, `error="insufficient_scope"` | The token lacks a permission the tool needs, such as `analytics.read`. Reconnect and approve it. |
| `400`, JSON-RPC error `-32000` | An MCP protocol version the server doesn't support. |
| `406`, `415` | The request lacks `Accept: application/json, text/event-stream` or `Content-Type: application/json`. |

### Public MCP server

The read-only server at `https://<your help center>/mcp` answers with JSON-RPC errors. See [The public MCP server of a help center](https://developers.helpcenter.io/content/public-mcp-server).

| Error | When |
| --- | --- |
| `404` `{"error": "not_found"}` | The help center has no public MCP server: it isn't public, is limited to certain IP addresses or to your app, hides from search engines, has the server switched off, or isn't live. |
| `429`, JSON-RPC error `429` | More than 120 requests a minute from your address, or 1,200 for the help center. Wait for `Retry-After` seconds. |
| `413` | A request body over 64 KiB. |
| `-32700` | The body isn't valid JSON. |
| `-32600` | A batch of messages (send one per request), or an unsupported `MCP-Protocol-Version` header. |
| `-32601` | An unknown method. |
| `-32602` | An unknown tool, or protocol parameters that don't match the request. Invalid tool arguments come back as a tool result with `isError` instead. |
| `-32020`, `-32022` | The `MCP-Protocol-Version`, `Mcp-Method` or `Mcp-Name` headers don't match the body, or name an unsupported protocol version. |

### Widget and embeds

The widget's requests to `https://embed.helpcenter.io` answer with `status`, `message` and `code`. See [Content Security Policy and allowed origins](https://developers.helpcenter.io/content/content-security-policy).

| Code | Status | When |
| --- | --- | --- |
| `WIDGET_NOT_FOUND` | 410 | No widget has the `app_id` in your snippet. |
| `SITE_AUTH_REQUIRED` | 403 | The help center is private and the request had no valid JWT. See [Sign readers into the widget with a JWT](https://developers.helpcenter.io/content/widget-jwt). |
| `SITE_IP_RESTRICTED` | 403 | The help center only opens from certain IP addresses, and this visitor's isn't one of them. |
| `SITE_UNAVAILABLE` | 403 | The help center is unavailable, so FAQ sections show nothing. |

### Single sign-on

Single sign-on returns no error codes. `/sso/jwt` answers in plain text: `400` `Missing jwt parameter.`, `401` `Invalid SSO token.`, or `500` `SSO is not properly configured.` Most other problems send the reader to the HelpCenter.io sign-in page, and a claim of the wrong type fails with an error page. See [Troubleshoot single sign-on](https://developers.helpcenter.io/content/troubleshoot-sso).

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| An HTML page where you expected JSON | No `Accept: application/json`, a typo in the path, or an unknown id on an article or category write. | Send the header, check the path and the id, and check the status before you parse. |
| A write answers `{"status": "success", "message": "This is the public API of HelpCenter.io"}` | Validation failed, and your client followed the `302` redirect to the API's welcome message. | Send `Accept: application/json` to get the error itself. |
| "The title field is required." although you sent a title | The request had no `Content-Type: application/json`, so the API didn't read the body. | Add the header. |
| `401` with a key that looks right | A header other than `apikey` (`api_key` and `X-API-Key` aren't read), a deleted key, or a key whose creator's HelpCenter.io account was deleted. | Send `apikey`, or create a key. |
| `401` `Unknown API key.` from `/v1/change-sets` | Change sets accept only API keys, not OAuth tokens. | Use an API key for change sets. |
| `404` for an article you see in the dashboard | The key belongs to another help center, or the article is in the Trash. | Check `GET /v1/sites`, or restore the article. |
| `422` "The include email field must be true or false." | `true` or `false` in a query string or form field. | Send `1` or `0`. |
| `429` although you send fewer than 300 requests a minute | Single-article, staged, version and change set reads count against the 120 writes; exports, bulk requests and image uploads share a limit per IP address; the edge allows about 2 requests a second. | See [Rate limits](https://developers.helpcenter.io/content/rate-limits). |
| `500` from `POST /v1/articles` | A title that is empty or only spaces in one of the languages you sent. | Send text in every title you include. |

## Related

- [Requests and responses](https://developers.helpcenter.io/content/requests-responses-and-conventions)
- [Rate limits](https://developers.helpcenter.io/content/rate-limits)
- [Scopes](https://developers.helpcenter.io/content/scopes)
- [Choose which help center a request acts on](https://developers.helpcenter.io/content/choosing-a-help-center)
