# Images

_Category: REST API reference_

Upload the images your articles show and get back a permanent URL to put in article HTML. You can send the file, give the https address of an image for HelpCenter.io to import, or create a single-use upload URL for a process that has the file but not your API key.

An article's `content` is HTML, so an illustrated article takes two steps: upload the image, then use its URL in an `<img>` tag when you create or update the article. Every upload response includes a ready-made HTML and Markdown snippet. To skip the first step, write the article with `rehost_images` and HelpCenter.io copies the images it links to (see [Articles](https://developers.helpcenter.io/content/articles-api)).

## Endpoints

| Method and path | What it does | Scope |
| --- | --- | --- |
| `POST /v1/images` | Upload one image, as a file or from an https address | `content.write` |
| `POST /v1/images/bulk` | Upload up to 20 images in one request | `content.write` |
| `POST /v1/images/uploads` | Create a single-use upload URL | `content.write` |
| `POST` to the `upload_url` | Upload one image to an upload URL, with no API key | None: the URL is the credential |
| `GET /v1/images` | List images, newest first | `content.read` |
| `GET /v1/images/{imageId}` | Get one image | `content.read` |
| `DELETE /v1/images/{imageId}` | Delete an image and its file | `content.write` |

A Read only key has `content.read`; a Read & write key also has `content.write`. OAuth access tokens work too, with the same scopes (see [Scopes](https://developers.helpcenter.io/content/scopes)).

## The image object

| Field | Type | Description |
| --- | --- | --- |
| `id` | integer | The image's id. |
| `url` | string | The image's permanent, absolute URL. Use it in article HTML. |
| `filename` | string | For an upload, the name of the file you sent. For an import, the last part of the address's path, with the extension of the detected type. |
| `mime_type` | string or null | `image/jpeg`, `image/png`, `image/gif` or `image/webp`, detected from the file itself. `null` on images stored before HelpCenter.io recorded it. |
| `size` | integer | Size in bytes. |
| `checksum` | string or null | The SHA-256 of the file, as 64 hex characters. `null` on images stored before HelpCenter.io recorded it. |
| `alt_text` | string | The `alt_text` you sent with this upload. It is not stored, so every read returns `""`. Alt text belongs in the article's HTML. |
| `snippets.html` | string | An `<img>` tag with the URL and the alt text, both HTML-escaped. |
| `snippets.markdown` | string | A Markdown image with the URL and the alt text. |
| `created_at` | string | When the image was stored, as `YYYY-MM-DD HH:MM:SS` in UTC, with no offset. |

This is the answer to a first upload. The image object sits under `image`, next to `reused`:

```
{
  "status": "success",
  "reused": false,
  "image": {
    "id": 3,
    "url": "https://helpcenter-io.s3.amazonaws.com/uploads/acme/76LPXpORQpiIyW5Bf4Wvo7HtHsyBOjgGGH7NNID3.png",
    "filename": "create-board.png",
    "mime_type": "image/png",
    "size": 52409,
    "checksum": "85a038dfadb5e2c6e1f801b31a2ea21b49b9187053571c5df999af581e429e2a",
    "alt_text": "The Create a board article",
    "snippets": {
      "html": "<img src=\"https://helpcenter-io.s3.amazonaws.com/uploads/acme/76LPXpORQpiIyW5Bf4Wvo7HtHsyBOjgGGH7NNID3.png\" alt=\"The Create a board article\">",
      "markdown": "![The Create a board article](https://helpcenter-io.s3.amazonaws.com/uploads/acme/76LPXpORQpiIyW5Bf4Wvo7HtHsyBOjgGGH7NNID3.png)"
    },
    "created_at": "2026-09-30 08:17:20"
  }
}
```

HelpCenter.io stores the file exactly as you send it: it does not resize, compress or convert images. Images stay in place when you delete an article that uses them.

## Upload an image

Send the image as `multipart/form-data` under `file`. A new image answers `201 Created`; bytes this help center already has answer `200 OK` with the existing image.

POST`/v1/images`

Upload one image file and get back its permanent URL.

| Field | Type | Description |
| --- | --- | --- |
| `file` | file | Required unless you send `source_url`. A JPEG, PNG, GIF or WebP image of up to 10 MB. |
| `source_url` | string | Required unless you send `file`. The https address of an image to import, up to 2,048 characters. See Import from a URL below. |
| `alt_text` | string | Optional, up to 255 characters. Used only to build `snippets` in this response. |
| `dedupe` | boolean | Defaults to on. Send `0` to store another copy of bytes this help center already has. |

### Which files are accepted

- JPEG, PNG, GIF and WebP, up to 10 MB (10,485,760 bytes) each.
- The type is read from the file's content, not from its name or the `Content-Type` you send. A text file renamed to `.png` is refused.
- SVG is not accepted, in any form.

### The same image twice

Uploads are matched by content. When this help center already has an image with the same bytes, you get that image back with `200 OK` and `"reused": true`, including its original `filename`, `url` and `created_at`. The file name you send plays no part, and an import and an upload of the same bytes resolve to the same image. That makes a failed import safe to run again: the files that already landed come back with the URLs you already have.

To store another copy anyway, send `dedupe` as `0`. In a multipart body a boolean must be `1` or `0`: the strings `true` and `false` are refused with `422 Unprocessable Entity` and `The dedupe field must be true or false.` In a JSON body, `true` and `false` work.

### Import from a URL

Instead of the file, send `source_url`: the https address of an image. HelpCenter.io downloads it and stores it like an upload, duplicate check included. This request can be JSON, so it suits clients that cannot send files, such as AI agents.

POST`/v1/images`

Import an image from an https address instead of sending the file.

The download follows these rules:

- The address, and every redirect, must be https. Up to 4 redirects are followed.
- Addresses that resolve to private or reserved networks are refused.
- A download, redirects included, has 15 seconds and 10 MB.
- The type is read from the downloaded bytes, so the file must be a JPEG, PNG, GIF or WebP image whatever the address ends with.

When the address is refused or the download fails, you get `422` with `The image at source_url could not be imported.` and the reason under `errors.source_url`, for example `The image could not be imported: the image returned HTTP 404.` The reasons you are most likely to meet:

| Reason | What to check |
| --- | --- |
| `the host could not be resolved` | The host name in the address. |
| `the host resolves to a private or reserved address` | The image must be on a public address. |
| `the image returned HTTP 404` | The address answered with that status. Any status other than 2xx fails. |
| `the image could not be downloaded in time` | The server took longer than 15 seconds. |
| `the image is larger than the 10485760-byte limit` | The file is over 10 MB. |
| `the file is not a JPEG, PNG, GIF or WebP image` | The address serves another type, such as SVG or an HTML page. |
| `the image redirected to an address that is not https` | A redirect went to an http address. |

Send either `file` or `source_url`, never both.

### Errors

| Status | Message | When |
| --- | --- | --- |
| `422` | `Send the image as multipart/form-data under "file", or its https address as "source_url".` | There is no file and no address. |
| `422` | `Unsupported image type. Upload a JPEG, PNG, GIF, or WebP image.` | The file is not one of the accepted types. |
| `422` | `The image is too large. The maximum upload size is 10 MB.` | The file is over 10 MB. |
| `422` | `The source_url must be an https:// address.` | The address is not https. |
| `422` | `Send either a file or a source_url, not both.` | You sent both. |
| `422` | `The image at source_url could not be imported.` | The import failed; the reason is in `errors.source_url`. |
| `403` | `This action requires the content.write scope.` | The key is Read only, or the token lacks `content.write`. |
| `500` | `The image could not be stored. Please try again.` | Storage failed. Nothing is left behind, so retry. |

Validation errors use the `{"status": "validation_error", "message": "The upload could not be accepted.", "errors": {…}}` shape, with each message under the field it concerns.

## Upload several images

Send up to 20 images in one request: files as `files[]`, addresses to import as `source_urls[]`, or both. The answer is always `207 Multi-Status` once the request is valid, with one result per image.

POST`/v1/images/bulk`

Upload up to 20 image files in one request, with a result for each.

Import several addresses with JSON:

POST`/v1/images/bulk`

Import up to 20 images from https addresses in one request.

- Items are matched by position: `alt_texts[0]` belongs to item 0. To mix files and addresses in one multipart request, give every item its own index, for example `files[0]` and `source_urls[1]`. One index cannot carry both.
- The limit of 20 counts files and addresses together.
- Every file is checked before anything is stored. One refused file fails the whole request with `422`, and nothing is written; the error names the item, such as `files.1`.
- Addresses are downloaded one at a time, so a refused or failed address is an `error` result inside the `207`, with code `import_failed`. All the downloads in one request share 45 seconds.
- A request body can be at most 32 MB, so batch large files by total size: 20 files of 10 MB do not fit in one request.

Read each entry in `results`, not the status code. The top-level `status` is `"success"` even when every item failed:

| Field | Description |
| --- | --- |
| `results[].index` | The item's position in the request. |
| `results[].status` | `created`, `reused` (the bytes were already stored) or `error`. |
| `results[].image` | The image object, for `created` and `reused`. |
| `results[].filename` or `results[].source_url` | On an error, which file or address it was. |
| `results[].error` | On an error, `code` (`import_failed` or `storage_failed`) and `message`. |
| `meta` | `total`, `created`, `reused` and `failed` counts. |

More than 20 items answers `422` with `Too many images in one request. The maximum is 20 per batch, files and source URLs together.` (or the files-only or URLs-only form of the same message).

## Create an upload URL

Use an upload URL when the process that has the file should not hold your API key: an AI agent, a browser, a build job. Your server creates the URL with the key, and passes it on. Whoever has the URL can upload one image to this help center, as the credential that created it, without a key of their own.

POST`/v1/images/uploads`

Create a single-use URL that accepts one image upload without an API key.

- An upload URL works for 15 minutes (`expires_at`) and accepts a single image of up to `max_bytes` bytes (10 MB).
- It is on `api.helpcenter.io`, and it is signed: use it exactly as returned.
- Anyone who has the URL can use it until it is spent or expires, so hand it only to the process that needs it.

## Upload to an upload URL

POST the image as `multipart/form-data` to the exact `upload_url`, query string included. Send no API key. The fields and the answer are the same as for `POST /v1/images`, except that `file` is required (`source_url` is not read here):

```
curl -sS -X POST "$UPLOAD_URL" \
  -H "Accept: application/json" \
  -F "file=@help-center-home.png" \
  -F "alt_text=The help center home page"
```

Quote the URL: it contains `&`. The response is `201 Created` (or `200 OK` with `"reused": true`) with the image, exactly as for `POST /v1/images`.

POST`/v1/images/uploads/{intent}`

Upload one image to an upload URL, with no API key: the URL is the credential.

When the URL is refused, the body is `{"status": "error", "code": "…", "message": "…"}`:

| Status | Code | When |
| --- | --- | --- |
| `403` | `upload_url_invalid` | The URL was changed, or it is not one HelpCenter.io issued. Create a new one. |
| `410` | `upload_url_expired` | The 15 minutes are over. Create a new one. |
| `410` | `upload_url_used` | An image was already uploaded to it. Each URL takes one upload. |
| `403` | `upload_url_revoked` | The key that created it can no longer upload images to this help center, for example because the key was deleted. |

A refused file (`422`) or a storage failure (`500`) does not spend the URL: correct the file and upload again to the same URL.

## List images

List the images uploaded through the API, newest first. The list includes images uploaded with any of the endpoints above, and the copies HelpCenter.io makes when an article is written with `rehost_images`. Images added in the article editor are not included.

GET`/v1/images`

List the images uploaded through the API, newest first.

| Parameter | Type | Description |
| --- | --- | --- |
| `limit` | integer | Images per page, 1 to 100. Defaults to 50. |
| `page` | integer | Page number, starting at 1. Defaults to 1. |
| `checksum` | string | Only images whose SHA-256 is exactly this. |

To check whether a file is already uploaded, hash it and ask for its checksum. You get the matching images, more than one if copies were stored with `dedupe` off, or an empty list:

```
CHECKSUM=$(shasum -a 256 create-board.png | cut -d ' ' -f 1)
curl -sS "https://api.helpcenter.io/v1/images?checksum=$CHECKSUM" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json"
```

Send `Accept: application/json`. Without it, an invalid `limit`, `page` or `checksum` is answered with a redirect instead of the `422` error. The `meta` block counts every matching image: `page`, `per_page`, `total_pages` and `items_count` (see [Pagination](https://developers.helpcenter.io/content/pagination)).

## Get an image

GET`/v1/images/{imageId}`

Get one image.

An id that belongs to another help center answers `404` with `Image not found.`, the same as an id that does not exist.

## Delete an image

Delete an image's record and its file. There is no undo: pages that still show the URL show a broken image.

DELETE`/v1/images/{imageId}`

Delete an image and its file, unless article content still uses it.

Before deleting, HelpCenter.io checks the current content of this help center's articles, drafts included, for the image's URL. When articles use it, the delete is refused with `409 Conflict` and `referenced_by` lists up to 20 of them, each with its `id` and `title` in every language. Update those articles first, or add `?force=true` to delete anyway.

The check covers only that current content. Articles in Trash, staged changes and earlier versions are not checked, so an image they use can be deleted without a `409`, and shows as broken if you restore or publish that content later.

## Rate limits

Every request made with your key or token counts toward its limits: 300 requests a minute, and for deletes also the 120 writes a minute. Uploads have extra limits per IP address:

- `POST /v1/images` and `POST /v1/images/uploads`: 60 a minute.
- `POST /v1/images/bulk`: 20 a minute.
- Uploads to an upload URL: 60 a minute, counted on their own.

The first two share one counter per IP address with `POST /v1/articles/bulk` and `GET /v1/export`, and each endpoint compares that shared count with its own limit. So 20 single uploads in a minute make the next bulk request from the same address answer `429 Too Many Requests`. Wait the number of seconds in the `Retry-After` header, then retry. See [Rate limits](https://developers.helpcenter.io/content/rate-limits).

## Errors

| Status | Body | When |
| --- | --- | --- |
| `401` | `{"status": "unauthorized"}` | The key or token is missing or unknown. |
| `403` | `insufficient_scope` | A Read only key, or a token without `content.write`, on an upload or delete. |
| `403`, `410` | `upload_url_…` | An upload URL was refused. See Upload to an upload URL above. |
| `404` | `Image not found.` | No image with that id in this help center. |
| `409` | `referenced_by` | A delete of an image that articles still use. |
| `422` | `validation_error` | A refused file, address or field. |
| `429` | `{"message": "Too Many Attempts."}` | A rate limit. Wait for `Retry-After`. |
| `500` | `The image could not be stored. Please try again.` | Storage failed. Retry. |

For the shapes every endpoint shares, see [Errors](https://developers.helpcenter.io/content/errors).

## Related

- [Articles](https://developers.helpcenter.io/content/articles-api)
- [Import articles in bulk](https://developers.helpcenter.io/content/import-articles-in-bulk)
- [Rate limits](https://developers.helpcenter.io/content/rate-limits)
